using System.Globalization;
using System.Reflection;
using XFESystem.Collections;
namespace XFESystem.Browser.Tests;
[Collection("Browser lifetime ownership")]
public sealed class BrowserOwnerReleaseTraceTests
{
private const BindingFlags PrivateInstance=BindingFlags.Instance|BindingFlags.NonPublic;
private static T Owner<T>(BrowserService browser,string field)where T:class=>
Assert.IsType<T>(typeof(BrowserService).GetField(field,PrivateInstance)!.GetValue(browser));
private static Dictionary<string,string> Fields(string record)
{
Assert.EndsWith("\n",record,StringComparison.Ordinal);
var parts=record.TrimEnd('\n').Split('|');Assert.Equal(1,parts.Length%2);
return Enumerable.Range(0,(parts.Length-1)/2).ToDictionary(index=>parts[1+index*2],index=>parts[2+index*2]);
}
private static string[] WeakRecords(IEnumerable<string> chunks)=>chunks.SelectMany(chunk=>chunk.Split('\n',StringSplitOptions.RemoveEmptyEntries))
.Where(line=>line.StartsWith("XFE_BROWSER_WEAK_RELEASE|",StringComparison.Ordinal)).Select(line=>line+"\n").ToArray();
private static void CheckReleased(WeakCollectionScope scope)
{
var after=scope.Lifetime;Assert.Equal(0,after.Tables);Assert.Equal(0,after.Entries);
Assert.Equal(after.Created,after.Disposed);Assert.Equal(after.TableHandlesCreated,after.TableHandlesDisposed);
}
private static void CheckRecord(string record,string reason,WeakCollectionLifetime? before=null,WeakCollectionLifetime? after=null)
{
var fields=Fields(record);Assert.Equal("0",fields["TablesAfter"]);Assert.Equal("0",fields["EntriesAfter"]);
Assert.Equal(fields["Created"],fields["Disposed"]);Assert.Equal(fields["OwnerHandlesCreated"],fields["OwnerHandlesDisposed"]);
Assert.Equal(reason,fields["Reason"]);Assert.True(long.Parse(fields["Page"],CultureInfo.InvariantCulture)>0);
if(before is not null){Assert.Equal(before.Tables.ToString(CultureInfo.InvariantCulture),fields["TablesBefore"]);Assert.Equal(before.Entries.ToString(CultureInfo.InvariantCulture),fields["EntriesBefore"]);}
if(after is not null){Assert.Equal(after.Created.ToString(CultureInfo.InvariantCulture),fields["Created"]);Assert.Equal(after.TableHandlesCreated.ToString(CultureInfo.InvariantCulture),fields["OwnerHandlesCreated"]);}
}
private static void AllocateOwners(BrowserService browser)
{
browser.Navigate("about:Start");
Assert.Equal("ready",browser.ExecuteJavaScript("globalThis.kept=document.createElement('button');document.body.append(kept);globalThis.map=new WeakMap([[kept,{node:kept}]]);setInterval(()=>map.has(kept),1000);'ready'"));
}
[Fact]
public void SuccessfulReleaseReportsActualEngineAndDomCountersAtomicallyAndRepeatedReleaseAddsNothing()
{
var packets=new List<string>();var browser=new BrowserService{Trace=packets.Add};AllocateOwners(browser);
var dom=Owner<DomBridge>(browser,"dom");var runtime=Owner<JavaScriptRuntime>(browser,"runtime");
var scope=((IWeakCollectionOwner)(object)runtime.Engine).WeakCollections;var before=scope.Lifetime;
long domBefore=dom.Lifetime.OutstandingHandles;packets.Clear();browser.Release();
CheckReleased(scope);Assert.Equal(0,runtime.PendingTaskCount);Assert.Equal(0,dom.Lifetime.OutstandingHandles);
Assert.Null(browser.Document);Assert.Null(browser.ReadWeakCollectionLifetime());Assert.Null(browser.ReadLifetimeDiagnostics());
var packet=Assert.Single(packets);Assert.StartsWith("XFE_BROWSER_RELEASE|HandlesBefore|"+domBefore+"|HandlesAfter|0\n",packet,StringComparison.Ordinal);
CheckRecord(Assert.Single(WeakRecords(packets)),"Release",before,scope.Lifetime);
browser.Release();Assert.Single(packets);GC.KeepAlive(runtime);GC.KeepAlive(dom);
}
[Fact]
public void NavigationReplacementReleasesOnlyOldOwnersAndReportsBothActualGenerations()
{
var packets=new List<string>();var browser=new BrowserService{Trace=packets.Add};AllocateOwners(browser);
var oldRuntime=Owner<JavaScriptRuntime>(browser,"runtime");var oldScope=((IWeakCollectionOwner)(object)oldRuntime.Engine).WeakCollections;
var oldBefore=oldScope.Lifetime;packets.Clear();var replacement=browser.Navigate("about:Start");
Assert.Contains("JavaScript ready: 12",replacement.Text,StringComparison.Ordinal);CheckReleased(oldScope);
var replacementRuntime=Owner<JavaScriptRuntime>(browser,"runtime");Assert.NotSame(oldRuntime,replacementRuntime);
var nextScope=((IWeakCollectionOwner)(object)replacementRuntime.Engine).WeakCollections;
Assert.True(nextScope.Lifetime.Entries>0);CheckRecord(Assert.Single(WeakRecords(packets)),"Replace",oldBefore,oldScope.Lifetime);
var nextBefore=nextScope.Lifetime;browser.Release();CheckReleased(nextScope);
var records=WeakRecords(packets);Assert.Equal(2,records.Length);CheckRecord(records[1],"Release",nextBefore,nextScope.Lifetime);
Assert.Equal(Fields(records[0])["Page"],Fields(records[1])["Page"]);GC.KeepAlive(oldRuntime);
}
[Fact]
public void FailureAfterReplacingOldPageReportsPartialNewRuntimeReleaseExactlyOnce()
{
var packets=new List<string>();var browser=new BrowserService(new TwoScriptsTransport(),new BrowserLimits{MaximumScripts=1}){Trace=packets.Add};
AllocateOwners(browser);var oldRuntime=Owner<JavaScriptRuntime>(browser,"runtime");
var oldScope=((IWeakCollectionOwner)(object)oldRuntime.Engine).WeakCollections;packets.Clear();
var page=browser.Navigate("http://failure.test/TwoScripts");Assert.Equal("Navigation failed",page.Title);Assert.Null(browser.Document);
CheckReleased(oldScope);var records=WeakRecords(packets);Assert.Equal(2,records.Length);
CheckRecord(records[0],"Replace");CheckRecord(records[1],"NavigationFailed");
browser.Release();Assert.Equal(2,WeakRecords(packets).Length);
Assert.Contains("JavaScript ready: 12",browser.Navigate("about:Start").Text,StringComparison.Ordinal);browser.Release();
}
[Theory]
[InlineData("Release")]
[InlineData("NavigationFailed")]
[InlineData("Replace")]
public void ThrowingDiagnosticObserverCannotInterruptActualOwnerCleanupOrLaterNavigation(string reason)
{
bool throwOnRelease=false;int observed=0;BrowserService? browser=null;WeakCollectionScope? oldScope=null;
string? observedPacket=null;WeakCollectionLifetime? observedScope=null;bool noOwnersAtCallback=false;
browser=new(){Trace=packet=>
{
if(!throwOnRelease||!packet.StartsWith("XFE_BROWSER_RELEASE|",StringComparison.Ordinal))return;
observed++;observedPacket=packet;observedScope=oldScope?.Lifetime;
noOwnersAtCallback=browser!.ReadWeakCollectionLifetime() is null&&browser.ReadLifetimeDiagnostics() is null;
throw new IOException("Controlled diagnostic observer failure.");
}};
AllocateOwners(browser);var runtime=Owner<JavaScriptRuntime>(browser,"runtime");var dom=Owner<DomBridge>(browser,"dom");
oldScope=((IWeakCollectionOwner)(object)runtime.Engine).WeakCollections;throwOnRelease=true;
if(reason=="Release")browser.Release();
else if(reason=="NavigationFailed")Assert.Equal("Navigation failed",browser.Navigate("about:Unsupported").Title);
else Assert.Contains("JavaScript ready: 12",browser.Navigate("about:Start").Text,StringComparison.Ordinal);
Assert.Equal(1,observed);CheckReleased(oldScope);Assert.Equal(0,dom.Lifetime.OutstandingHandles);Assert.Equal(0,runtime.PendingTaskCount);
Assert.NotNull(observedPacket);Assert.Contains("|Reason|"+reason+"\n",observedPacket,StringComparison.Ordinal);
Assert.Equal(oldScope.Lifetime,observedScope);Assert.True(noOwnersAtCallback);
throwOnRelease=false;Assert.Contains("JavaScript ready: 12",browser.Navigate("about:Start").Text,StringComparison.Ordinal);browser.Release();
}
private sealed class TwoScriptsTransport:IWebTransport
{
public WebResponse Get(Uri address,BrowserLimits limits)=>new(address,200,"text/html","<html><body><script>1</script><script>2</script></body></html>");
}
}
using System.Globalization;
using System.Reflection;
using XFESystem.Collections;
namespace XFESystem.Browser.Tests;
[Collection("Browser lifetime ownership")]
public sealed class BrowserOwnerReleaseTraceTests
{
private const BindingFlags PrivateInstance=BindingFlags.Instance|BindingFlags.NonPublic;
private static T Owner<T>(BrowserService browser,string field)where T:class=>
Assert.IsType<T>(typeof(BrowserService).GetField(field,PrivateInstance)!.GetValue(browser));
private static Dictionary<string,string> Fields(string record)
{
Assert.EndsWith("\n",record,StringComparison.Ordinal);
var parts=record.TrimEnd('\n').Split('|');Assert.Equal(1,parts.Length%2);
return Enumerable.Range(0,(parts.Length-1)/2).ToDictionary(index=>parts[1+index*2],index=>parts[2+index*2]);
}
private static string[] WeakRecords(IEnumerable<string> chunks)=>chunks.SelectMany(chunk=>chunk.Split('\n',StringSplitOptions.RemoveEmptyEntries))
.Where(line=>line.StartsWith("XFE_BROWSER_WEAK_RELEASE|",StringComparison.Ordinal)).Select(line=>line+"\n").ToArray();
private static void CheckReleased(WeakCollectionScope scope)
{
var after=scope.Lifetime;Assert.Equal(0,after.Tables);Assert.Equal(0,after.Entries);
Assert.Equal(after.Created,after.Disposed);Assert.Equal(after.TableHandlesCreated,after.TableHandlesDisposed);
}
private static void CheckRecord(string record,string reason,WeakCollectionLifetime? before=null,WeakCollectionLifetime? after=null)
{
var fields=Fields(record);Assert.Equal("0",fields["TablesAfter"]);Assert.Equal("0",fields["EntriesAfter"]);
Assert.Equal(fields["Created"],fields["Disposed"]);Assert.Equal(fields["OwnerHandlesCreated"],fields["OwnerHandlesDisposed"]);
Assert.Equal(reason,fields["Reason"]);Assert.True(long.Parse(fields["Page"],CultureInfo.InvariantCulture)>0);
if(before is not null){Assert.Equal(before.Tables.ToString(CultureInfo.InvariantCulture),fields["TablesBefore"]);Assert.Equal(before.Entries.ToString(CultureInfo.InvariantCulture),fields["EntriesBefore"]);}
if(after is not null){Assert.Equal(after.Created.ToString(CultureInfo.InvariantCulture),fields["Created"]);Assert.Equal(after.TableHandlesCreated.ToString(CultureInfo.InvariantCulture),fields["OwnerHandlesCreated"]);}
}
private static void AllocateOwners(BrowserService browser)
{
browser.Navigate("about:Start");
Assert.Equal("ready",browser.ExecuteJavaScript("globalThis.kept=document.createElement('button');document.body.append(kept);globalThis.map=new WeakMap([[kept,{node:kept}]]);setInterval(()=>map.has(kept),1000);'ready'"));
}
[Fact]
public void SuccessfulReleaseReportsActualEngineAndDomCountersAtomicallyAndRepeatedReleaseAddsNothing()
{
var packets=new List<string>();var browser=new BrowserService{Trace=packets.Add};AllocateOwners(browser);
var dom=Owner<DomBridge>(browser,"dom");var runtime=Owner<JavaScriptRuntime>(browser,"runtime");
var scope=((IWeakCollectionOwner)(object)runtime.Engine).WeakCollections;var before=scope.Lifetime;
long domBefore=dom.Lifetime.OutstandingHandles;packets.Clear();browser.Release();
CheckReleased(scope);Assert.Equal(0,runtime.PendingTaskCount);Assert.Equal(0,dom.Lifetime.OutstandingHandles);
Assert.Null(browser.Document);Assert.Null(browser.ReadWeakCollectionLifetime());Assert.Null(browser.ReadLifetimeDiagnostics());
var packet=Assert.Single(packets);Assert.StartsWith("XFE_BROWSER_RELEASE|HandlesBefore|"+domBefore+"|HandlesAfter|0\n",packet,StringComparison.Ordinal);
CheckRecord(Assert.Single(WeakRecords(packets)),"Release",before,scope.Lifetime);
browser.Release();Assert.Single(packets);GC.KeepAlive(runtime);GC.KeepAlive(dom);
}
[Fact]
public void NavigationReplacementReleasesOnlyOldOwnersAndReportsBothActualGenerations()
{
var packets=new List<string>();var browser=new BrowserService{Trace=packets.Add};AllocateOwners(browser);
var oldRuntime=Owner<JavaScriptRuntime>(browser,"runtime");var oldScope=((IWeakCollectionOwner)(object)oldRuntime.Engine).WeakCollections;
var oldBefore=oldScope.Lifetime;packets.Clear();var replacement=browser.Navigate("about:Start");
Assert.Contains("JavaScript ready: 12",replacement.Text,StringComparison.Ordinal);CheckReleased(oldScope);
var replacementRuntime=Owner<JavaScriptRuntime>(browser,"runtime");Assert.NotSame(oldRuntime,replacementRuntime);
var nextScope=((IWeakCollectionOwner)(object)replacementRuntime.Engine).WeakCollections;
Assert.True(nextScope.Lifetime.Entries>0);CheckRecord(Assert.Single(WeakRecords(packets)),"Replace",oldBefore,oldScope.Lifetime);
var nextBefore=nextScope.Lifetime;browser.Release();CheckReleased(nextScope);
var records=WeakRecords(packets);Assert.Equal(2,records.Length);CheckRecord(records[1],"Release",nextBefore,nextScope.Lifetime);
Assert.Equal(Fields(records[0])["Page"],Fields(records[1])["Page"]);GC.KeepAlive(oldRuntime);
}
[Fact]
public void FailureAfterReplacingOldPageReportsPartialNewRuntimeReleaseExactlyOnce()
{
var packets=new List<string>();var browser=new BrowserService(new TwoScriptsTransport(),new BrowserLimits{MaximumScripts=1}){Trace=packets.Add};
AllocateOwners(browser);var oldRuntime=Owner<JavaScriptRuntime>(browser,"runtime");
var oldScope=((IWeakCollectionOwner)(object)oldRuntime.Engine).WeakCollections;packets.Clear();
var page=browser.Navigate("http://failure.test/TwoScripts");Assert.Equal("Navigation failed",page.Title);Assert.Null(browser.Document);
CheckReleased(oldScope);var records=WeakRecords(packets);Assert.Equal(2,records.Length);
CheckRecord(records[0],"Replace");CheckRecord(records[1],"NavigationFailed");
browser.Release();Assert.Equal(2,WeakRecords(packets).Length);
Assert.Contains("JavaScript ready: 12",browser.Navigate("about:Start").Text,StringComparison.Ordinal);browser.Release();
}
[Theory]
[InlineData("Release")]
[InlineData("NavigationFailed")]
[InlineData("Replace")]
public void ThrowingDiagnosticObserverCannotInterruptActualOwnerCleanupOrLaterNavigation(string reason)
{
bool throwOnRelease=false;int observed=0;BrowserService? browser=null;WeakCollectionScope? oldScope=null;
string? observedPacket=null;WeakCollectionLifetime? observedScope=null;bool noOwnersAtCallback=false;
browser=new(){Trace=packet=>
{
if(!throwOnRelease||!packet.StartsWith("XFE_BROWSER_RELEASE|",StringComparison.Ordinal))return;
observed++;observedPacket=packet;observedScope=oldScope?.Lifetime;
noOwnersAtCallback=browser!.ReadWeakCollectionLifetime() is null&&browser.ReadLifetimeDiagnostics() is null;
throw new IOException("Controlled diagnostic observer failure.");
}};
AllocateOwners(browser);var runtime=Owner<JavaScriptRuntime>(browser,"runtime");var dom=Owner<DomBridge>(browser,"dom");
oldScope=((IWeakCollectionOwner)(object)runtime.Engine).WeakCollections;throwOnRelease=true;
if(reason=="Release")browser.Release();
else if(reason=="NavigationFailed")Assert.Equal("Navigation failed",browser.Navigate("about:Unsupported").Title);
else Assert.Contains("JavaScript ready: 12",browser.Navigate("about:Start").Text,StringComparison.Ordinal);
Assert.Equal(1,observed);CheckReleased(oldScope);Assert.Equal(0,dom.Lifetime.OutstandingHandles);Assert.Equal(0,runtime.PendingTaskCount);
Assert.NotNull(observedPacket);Assert.Contains("|Reason|"+reason+"\n",observedPacket,StringComparison.Ordinal);
Assert.Equal(oldScope.Lifetime,observedScope);Assert.True(noOwnersAtCallback);
throwOnRelease=false;Assert.Contains("JavaScript ready: 12",browser.Navigate("about:Start").Text,StringComparison.Ordinal);browser.Release();
}
private sealed class TwoScriptsTransport:IWebTransport
{
public WebResponse Get(Uri address,BrowserLimits limits)=>new(address,200,"text/html","<html><body><script>1</script><script>2</script></body></html>");
}
}