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(BrowserService browser,string field)where T:class=> Assert.IsType(typeof(BrowserService).GetField(field,PrivateInstance)!.GetValue(browser)); private static Dictionary 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 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();var browser=new BrowserService{Trace=packets.Add};AllocateOwners(browser); var dom=Owner(browser,"dom");var runtime=Owner(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();var browser=new BrowserService{Trace=packets.Add};AllocateOwners(browser); var oldRuntime=Owner(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(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();var browser=new BrowserService(new TwoScriptsTransport(),new BrowserLimits{MaximumScripts=1}){Trace=packets.Add}; AllocateOwners(browser);var oldRuntime=Owner(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(browser,"runtime");var dom=Owner(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",""); } }