Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

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
2 changes: 1 addition & 1 deletion src/AngleSharp.Js.Docs/package.json
Original file line number Diff line number Diff line change
@@ -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": [
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();
}
}
}
}
}
31 changes: 31 additions & 0 deletions src/AngleSharp.Js.Tests/SameObjectIdentityTests.cs
Original file line number Diff line number Diff line change
@@ -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("<body id=target>test</body>")).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<JsScriptingService>().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());
}
}
}
32 changes: 31 additions & 1 deletion src/AngleSharp.Js.Tests/WorkerTests.cs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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("<!doctype html>")).ConfigureAwait(false);
Assert.Throws<DomException>(() => new AngleSharp.Js.Dom.Worker(document.DefaultView, "/worker.js"));
var before = CountProcessThreads();

for (var i = 0; i < 32; i++)
{
Assert.Throws<DomException>(() => 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()
{
Expand Down Expand Up @@ -133,4 +163,4 @@ private static async Task WaitForResultAsync(AngleSharp.Dom.IDocument document,
}
}
}
}
}
52 changes: 39 additions & 13 deletions src/AngleSharp.Js/Dom/Worker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,28 +60,54 @@ public Worker(IWindow window, String source)

var parentContext = _window.Document.Context;
_scripting = parentContext.GetService<JsScriptingService>() ?? throw new DomException(DomError.NotSupported);
EnsureLoaderAvailable();
var workerUrl = ResolveUrl(source);
_pendingMessages = new Queue<Object>();

// 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<Object>();
foreach (var service in parentContext.OriginalServices)
{
if (service is EngineCreator || service is Func<IBrowsingContext, EngineCreator>)
{
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
Expand Down Expand Up @@ -289,4 +315,4 @@ private static void Enqueue(IEventLoop loop, TaskPriority priority, Action actio
#endregion

}
}
}
Loading
Loading