diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b7d77..9bfc820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.1.1 + +Released on Wednesday, October 7 2026. + +- Fixed background thread leaks when worker construction is rejected (#143) @PrzemyslawKlys +- Fixed canonical DOM node identity and concrete prototypes through `DomSameObject` getters (#141) @PrzemyslawKlys +- Added `JsScriptingOptions.EngineCreator` to create a configured Jint engine before any page script runs (#142) @PrzemyslawKlys + # 1.1.0 Released on Saturday, September 5 2026. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4583bc6..6db8b7d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -13,6 +13,7 @@ AngleSharp.Js contains code written by (in order of first pull request / commit) * [Marko Lahma](https://github.com/lahma) * [Bogdan Maltcev](https://github.com/badnickname) * [arekdygas](https://github.com/arekdygas) +* [Przemysław Kłys](https://github.com/PrzemyslawKlys) Without these awesome people AngleSharp.Js could not exist. Thanks to everyone for your contributions! :beers: 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.Docs/package.json b/src/AngleSharp.Js.Docs/package.json index 7e7e7c4..b7ca4a5 100644 --- a/src/AngleSharp.Js.Docs/package.json +++ b/src/AngleSharp.Js.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/js", - "version": "1.1.0", + "version": "1.1.1", "preview": true, "description": "The doclet for the AngleSharp.Js documentation.", "keywords": [ 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.Tests/SameObjectIdentityTests.cs b/src/AngleSharp.Js.Tests/SameObjectIdentityTests.cs new file mode 100644 index 0000000..9fdfff1 --- /dev/null +++ b/src/AngleSharp.Js.Tests/SameObjectIdentityTests.cs @@ -0,0 +1,31 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Dom; + using AngleSharp.Scripting; + using Jint; + using NUnit.Framework; + using System.Threading.Tasks; + + [TestFixture] + public class SameObjectIdentityTests + { + [Test] + public async Task MutationTargetUsesTheCanonicalNodeAndItsConcretePrototype() + { + using var context = BrowsingContext.New(Configuration.Default.WithJs()); + var document = await context.OpenAsync(response => response.Content("test")).ConfigureAwait(false); + IMutationRecord record = null; + var observer = new MutationObserver((records, _) => record = records[0]); + observer.Connect(document.Body, attributes: true); + document.Body.SetAttribute("data-value", "changed"); + observer.Disconnect(); + Assert.IsNotNull(record); + var engine = context.GetService().GetOrCreateJint(document); + engine.SetValue("observedRecord", record); + engine.Execute("document.body.marker = 'retained'"); + Assert.AreEqual("target", engine.Evaluate("observedRecord.target.id").ToString()); + Assert.IsTrue(engine.Evaluate("observedRecord.target === document.body").AsBoolean()); + Assert.AreEqual("retained", engine.Evaluate("observedRecord.target.marker").ToString()); + } + } +} diff --git a/src/AngleSharp.Js.Tests/WorkerTests.cs b/src/AngleSharp.Js.Tests/WorkerTests.cs index c0da60e..63a9322 100644 --- a/src/AngleSharp.Js.Tests/WorkerTests.cs +++ b/src/AngleSharp.Js.Tests/WorkerTests.cs @@ -1,9 +1,11 @@ namespace AngleSharp.Js.Tests { + using AngleSharp.Dom; using AngleSharp.Io; using AngleSharp.Js.Tests.Mocks; using NUnit.Framework; using System; + using System.Diagnostics; using System.Threading.Tasks; [TestFixture] @@ -90,6 +92,34 @@ public async Task WorkerShouldRequireResourceLoading() Assert.AreEqual("true", result); } + [Test] + [NonParallelizable] + public async Task RejectedWorkersShouldNotLeaveRunningThreads() + { + using (var context = BrowsingContext.New(Configuration.Default.WithJs())) + { + var document = await context.OpenAsync(request => request.Content("")).ConfigureAwait(false); + Assert.Throws(() => new AngleSharp.Js.Dom.Worker(document.DefaultView, "/worker.js")); + var before = CountProcessThreads(); + + for (var i = 0; i < 32; i++) + { + Assert.Throws(() => new AngleSharp.Js.Dom.Worker(document.DefaultView, "/worker.js")); + } + + // Allow unrelated runtime threads without hiding one surviving loop per failure. + Assert.Less(CountProcessThreads() - before, 16); + } + } + + private static Int32 CountProcessThreads() + { + using (var process = Process.GetCurrentProcess()) + { + return process.Threads.Count; + } + } + [Test] public async Task WorkerDirectPostToOwnerShouldRaiseMessage() { @@ -133,4 +163,4 @@ private static async Task WaitForResultAsync(AngleSharp.Dom.IDocument document, } } } -} \ No newline at end of file +} diff --git a/src/AngleSharp.Js/Dom/Worker.cs b/src/AngleSharp.Js/Dom/Worker.cs index 1185dca..854a31a 100644 --- a/src/AngleSharp.Js/Dom/Worker.cs +++ b/src/AngleSharp.Js/Dom/Worker.cs @@ -60,28 +60,54 @@ public Worker(IWindow window, String source) var parentContext = _window.Document.Context; _scripting = parentContext.GetService() ?? throw new DomException(DomError.NotSupported); + EnsureLoaderAvailable(); + var workerUrl = ResolveUrl(source); + _pendingMessages = new Queue(); + + // Check the parent's capabilities before starting a thread that a failed constructor + // would leave unreachable. Release it if context creation or scheduling fails too. _workerLoop = new JsEventLoop(); - var workerConfig = Configuration.Default - .With(_scripting) - .WithOnly(_workerLoop); - _workerContext = BrowsingContext.New(workerConfig); + try + { + var workerConfig = Configuration.Default + .With(_scripting) + .WithOnly(_workerLoop); - _pendingMessages = new Queue(); + foreach (var service in parentContext.OriginalServices) + { + if (service is EngineCreator || service is Func) + { + workerConfig = workerConfig.With(service); + } + } - EnsureLoaderAvailable(); + _workerContext = BrowsingContext.New(workerConfig); - var workerUrl = ResolveUrl(source); - Enqueue(_workerLoop, TaskPriority.Critical, () => + Enqueue(_workerLoop, TaskPriority.Critical, () => + { + try + { + InitializeWorker(workerUrl); + } + catch (Exception ex) + { + _startupError = ex; + } + }); + } + catch { try { - InitializeWorker(workerUrl); + _workerContext?.Dispose(); } - catch (Exception ex) + finally { - _startupError = ex; + ((IDisposable)_workerLoop).Dispose(); } - }); + + throw; + } } #endregion @@ -289,4 +315,4 @@ private static void Enqueue(IEventLoop loop, TaskPriority priority, Action actio #endregion } -} \ No newline at end of file +} 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 00b67e5..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(); @@ -122,6 +118,13 @@ public JsValue GetSameObject(Object owner, MethodInfo getter) return JsValue.Null; } + // Nodes and windows already have a canonical proxy. Preserve its identity, + // concrete prototype and script-owned properties across SameObject getters. + if (current is INode || current is IWindow) + { + return GetDomNode(current); + } + var cache = _sameObjects.GetValue(owner, _ => new SameObjectCache()); return cache.GetOrUpdate(getter, current, () => CreateInstance(current, getter.ReturnType)); } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index cc86f12..dc8033a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,7 +10,7 @@ entry of CHANGELOG.md, which build/Build.cs passes in as -p:Version, so this value only affects a plain `dotnet build` and no longer needs bumping for a release. --> - 1.1.0 + 1.1.1