using System.Runtime.CompilerServices;
using AngleSharp.Html.Parser;
using XFESystem.Core;
using XFESystem.Desktop;
using Xunit.Abstractions;
namespace XFESystem.Browser.Tests;
[CollectionDefinition("Browser lifetime ownership",DisableParallelization=true)]
public sealed class BrowserLifetimeCollection;
[Collection("Browser lifetime ownership")]
public sealed class BrowserLifetimeTests(ITestOutputHelper output)
{
private sealed class Key;
private sealed class Value(Key key){public Key BackReference=key;}
[MethodImpl(MethodImplOptions.NoInlining)]
private static (WeakReference<Key>,WeakReference<Value>) AddCycle(DependentIdentityMap<Key,Value> map)
{var key=new Key();var value=new Value(key);map.Add(key,value);return(new(key),new(value));}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference<Value> AddWithAliveKey(DependentIdentityMap<Key,Value> map,Key key)
{var value=new Value(key);map.Add(key,value);return new(value);}
private static void Collect(){GC.Collect();GC.WaitForPendingFinalizers();GC.Collect();}
[Fact]
public void OwnedEphemeronsCollectUnreachableCyclesAndExplicitlyFreePrunedHandles()
{
var map=new DependentIdentityMap<Key,Value>();var weak=AddCycle(map);Collect();
Assert.False(weak.Item1.TryGetTarget(out _));Assert.False(weak.Item2.TryGetTarget(out _));
Assert.Equal(0,map.Count);Assert.Equal(1,map.HandlesCreated);Assert.Equal(1,map.HandlesDisposed);
map.Clear();map.Clear();Assert.Equal(1,map.HandlesDisposed);
var key=new Key();var live=AddWithAliveKey(map,key);Collect();
Assert.True(live.TryGetTarget(out var value));Assert.Same(value,map[key]);Assert.Same(key,value!.BackReference);
Assert.True(map.Remove(key));Assert.False(map.Remove(key));Assert.Equal(2,map.HandlesDisposed);GC.KeepAlive(key);
}
[Fact]
public void DetachedJavascriptReferencesKeepIdentityListenersAndCanReattachAfterCollection()
{
var runtime=new JavaScriptRuntime();var document=new HtmlParser().ParseDocument("<body></body>");
var dom=new DomBridge(runtime,document,new("http://lifetime.test/"),new());
Assert.True(runtime.Evaluate("globalThis.saved=document.createElement('button');saved.id='Saved';document.body.append(saved);saved.remove();globalThis.clicked=0;saved.addEventListener('click',()=>clicked++);globalThis.originalId=saved.__nodeId;'ready'").Success);
Collect();
Assert.Equal("true",runtime.Evaluate("document.body.append(saved);saved.click();document.getElementById('Saved')===saved&&saved.__nodeId===originalId&&clicked===1").Text);
int identity=int.Parse(runtime.Evaluate("saved.__nodeId").Text);
Assert.Same(document.GetElementById("Saved"),dom.ResolveNode(identity));
dom.ReleaseRetainedState();Assert.Equal(0,dom.Lifetime.OutstandingHandles);GC.KeepAlive(runtime);
}
[Fact]
public void DetachedDomCanvasFormAndListenerCyclesDoNotRemainPageRoots()
{
var runtime=new JavaScriptRuntime();var document=new HtmlParser().ParseDocument("<body></body>");
var dom=new DomBridge(runtime,document,new("http://lifetime.test/"),new());
var created=runtime.Evaluate("for(let i=0;i<20;i++){let c=document.createElement('canvas');c.width=4;c.height=4;document.body.append(c);c.getContext('2d').fillRect(0,0,2,2);c.remove();let a=document.createElement('textarea');a.value='temporary';a.setSelectionRange(1,2);document.body.append(a);a.remove();let n=document.createElement('span');n.textContent='gone';n.addEventListener('click',(function(node){return()=>node.textContent})(n));document.body.append(n);n.remove();c=null;a=null;n=null;} 'done'");
Assert.True(created.Success,created.Error);
Assert.True(runtime.Evaluate("document.body.textContent='';void 0").Success);
var before=dom.Lifetime;Collect();var after=dom.Lifetime;
output.WriteLine("Detached before={0}; after={1}",before,after);
Assert.InRange(after.Wrappers,1,12);Assert.InRange(after.Identities,1,15);
Assert.Equal(0,after.Canvases);Assert.Equal(0,after.FormSelections);
Assert.True(after.HandlesDisposed>100);Assert.True(after.OutstandingHandles<before.OutstandingHandles/4);
dom.ReleaseRetainedState();Assert.Equal(dom.Lifetime.HandlesCreated,dom.Lifetime.HandlesDisposed);GC.KeepAlive(runtime);
}
[Fact]
public void OriginalMonacoHelloCSharpIdleKeepsLiveDomAndMemoryBoundedWhileTimersContinue()
{
var storage=new MemoryWorkspace();string source=storage.ReadText(SystemPaths.Combine(SystemPaths.Documents,"Hello.cs"));
var browser=new BrowserService();browser.ResizeViewport(796,404);browser.NavigateEditor(source,"Hello.cs");
long baseline=0;long firstRevision=0;BrowserDomLifetime? first=null;
for(int step=0;step<=200;step++)
{
browser.AdvanceTime(step*500);browser.Layout(new(796,404));browser.ReadMonacoProof();
if(step%25!=0)continue;
Collect();var lifetime=browser.ReadLifetimeDiagnostics()!;long memory=GC.GetTotalMemory(true);
var weak=browser.ReadWeakCollectionLifetime()!;
output.WriteLine("JS weak tables={0}, entries={1}, created={2}, disposed={3}",weak.Tables,weak.Entries,weak.Created,weak.Disposed);
if(step>=50)Assert.InRange(weak.Created-weak.Disposed,0,4096);
output.WriteLine("Step {0}: bytes={1}, wrappers={2}, identities={3}, created={4}, disposed={5}, layout={6}, revision={7}",
step,memory,lifetime.Wrappers,lifetime.Identities,lifetime.HandlesCreated,lifetime.HandlesDisposed,browser.LayoutPassCount,browser.Revision);
if(step==50){baseline=memory;firstRevision=browser.Revision;first=lifetime;}
if(step>=50)
{
Assert.InRange(lifetime.Wrappers,1,400);Assert.InRange(lifetime.Identities,1,500);
Assert.InRange(lifetime.OutstandingHandles,1,1024);Assert.InRange(memory-baseline,-8_388_608,8_388_608);
}
}
var last=browser.ReadLifetimeDiagnostics()!;
Assert.True(browser.Revision>firstRevision+100);Assert.True(browser.LayoutPassCount>100);
// With a working innerText setter Monaco can measure its nonbreaking
// space probe once. It no longer retries zero-width font measurements
// throughout idle, so disposal churn itself is not a lifetime invariant.
Assert.InRange(last.OutstandingHandles-first!.OutstandingHandles,-1024,32);
Assert.Equal(source,browser.ReadEditorText());
browser.Release();Assert.Null(browser.Document);GC.KeepAlive(browser);
}
[Fact]
public void ReadyActorRetriesRejectedRetirementAndReleasesOnlyOnItsWorker()
{
var scheduler=new Scheduler();var factory=new Factory(scheduler);long clock=0;
var service=new BrowserEditorPreparationService(factory,scheduler,new CanvasFactory(),threadIdentity:()=>scheduler.Running?2:1,monotonicMilliseconds:()=>++clock);
var operation=service.Begin(new(1,1,"const answer=42;","Hello.js",796,404));scheduler.RunAll();
Assert.True(operation.TryTakeResult(out var result));Assert.Null(result.Error);var actor=Assert.IsType<BackgroundBrowserEditorSession>(result.Page);
var backend=factory.Page!;Assert.NotNull(backend.Document);
scheduler.Reject=true;actor.Cancel();Assert.False(actor.IsRetired);actor.AdvanceTime(++clock);Assert.False(actor.IsRetired);
Assert.NotNull(backend.Document);scheduler.Reject=false;actor.AdvanceTime(++clock);scheduler.RunAll();
Assert.True(actor.IsRetired);Assert.Null(backend.Document);Assert.Equal(0,scheduler.WorkerEnqueues);Assert.True(factory.ReleaseObservedOnWorker);
}
[Fact]
public void CancelledPublishedOperationRetainsCleanupUntilQueueAcceptsIt()
{
var scheduler=new Scheduler();var factory=new Factory(scheduler);long clock=0;
var service=new BrowserEditorPreparationService(factory,scheduler,new CanvasFactory(),monotonicMilliseconds:()=>++clock);
var operation=service.Begin(new(1,1,"const answer=42;","Hello.js",796,404));scheduler.RunAll();
Assert.NotNull(factory.Page!.Document);
scheduler.Reject=true;operation.Cancel();Assert.False(operation.TryTakeResult(out _));Assert.False(operation.IsRetired);
operation.AdvanceRetirement(++clock);Assert.False(operation.IsRetired);Assert.NotNull(factory.Page!.Document);
scheduler.Reject=false;operation.AdvanceRetirement(++clock);scheduler.RunAll();operation.AdvanceRetirement(++clock);
Assert.True(operation.IsRetired);Assert.Null(factory.Page.Document);Assert.Equal(0,scheduler.WorkerEnqueues);
}
[Fact]
public void CancellationAtPreparedPublicationBoundaryReleasesOwnedBackendWithoutWorkerEnqueue()
{
var scheduler=new Scheduler();var factory=new Factory(scheduler);IEditorPreparationOperation? operation=null;bool prepared=false;
var service=new BrowserEditorPreparationService(factory,scheduler,new CanvasFactory(),line=>
{if(line.StartsWith("XFE_EDITOR_BACKGROUND|Prepared|",StringComparison.Ordinal)){prepared=true;operation!.Cancel();}});
operation=service.Begin(new(1,1,"const answer=42;","Hello.js",796,404));scheduler.RunAll();
Assert.True(prepared);Assert.False(operation.TryTakeResult(out _));Assert.True(operation.IsRetired);Assert.Null(factory.Page!.Document);
Assert.True(factory.ReleaseObservedOnWorker);Assert.Equal(0,scheduler.WorkerEnqueues);
}
private sealed class Scheduler:IEditorBackgroundWorkScheduler
{
private readonly Queue<Action> queue=[];public bool Reject,Running;public int WorkerEnqueues;
public bool TryQueue(Action work){if(Running){WorkerEnqueues++;throw new InvalidOperationException("The scheduler has one UI producer.");}if(Reject)return false;queue.Enqueue(work);return true;}
public void RunAll(){int count=0;while(queue.TryDequeue(out var work)){Assert.True(++count<20);Running=true;try{work();}finally{Running=false;}}}
}
private sealed class Factory(Scheduler scheduler):IInteractiveBrowserFactory
{
public BrowserService? Page;public bool ReleaseObservedOnWorker;
public IInteractiveBrowserSession CreatePageSession()=>Page=new(){Trace=line=>{if(line.StartsWith("XFE_BROWSER_RELEASE|",StringComparison.Ordinal)){Assert.True(scheduler.Running);Assert.Contains("|HandlesAfter|0",line,StringComparison.Ordinal);Assert.Contains("XFE_BROWSER_WEAK_RELEASE|",line,StringComparison.Ordinal);Assert.Contains("|TablesAfter|0|EntriesAfter|0|",line,StringComparison.Ordinal);ReleaseObservedOnWorker=true;}}};
}
private sealed class CanvasFactory:IEditorPreparationCanvasFactory{public IDesktopCanvas CreateEditorPreparationCanvas()=>new Canvas();}
private sealed class Canvas:IDesktopWebCanvas
{
public int Width=>796;public int Height=>404;
public void Fill(uint rgb,int x,int y,int w,int h){}public void Line(uint rgb,int x1,int y1,int x2,int y2){}
public void Text(string text,uint rgb,int x,int y){}public void Present(){}public void PushClip(DesktopBounds clip){}public void PopClip(){}
public double MeasureWebText(string text,double size,string family,int weight)=>new BrowserFallbackTextMetrics().Measure(text,new(size,family,weight));
public (int Width,int Height,byte[] Alpha) RasterizeWebText(string text,double size,string family,int weight)=>(0,0,[]);
public void WebText(string text,uint color,double x,double y,double size,string family,int weight,double opacity=1){}
}
}
using System.Runtime.CompilerServices;
using AngleSharp.Html.Parser;
using XFESystem.Core;
using XFESystem.Desktop;
using Xunit.Abstractions;
namespace XFESystem.Browser.Tests;
[CollectionDefinition("Browser lifetime ownership",DisableParallelization=true)]
public sealed class BrowserLifetimeCollection;
[Collection("Browser lifetime ownership")]
public sealed class BrowserLifetimeTests(ITestOutputHelper output)
{
private sealed class Key;
private sealed class Value(Key key){public Key BackReference=key;}
[MethodImpl(MethodImplOptions.NoInlining)]
private static (WeakReference<Key>,WeakReference<Value>) AddCycle(DependentIdentityMap<Key,Value> map)
{var key=new Key();var value=new Value(key);map.Add(key,value);return(new(key),new(value));}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference<Value> AddWithAliveKey(DependentIdentityMap<Key,Value> map,Key key)
{var value=new Value(key);map.Add(key,value);return new(value);}
private static void Collect(){GC.Collect();GC.WaitForPendingFinalizers();GC.Collect();}
[Fact]
public void OwnedEphemeronsCollectUnreachableCyclesAndExplicitlyFreePrunedHandles()
{
var map=new DependentIdentityMap<Key,Value>();var weak=AddCycle(map);Collect();
Assert.False(weak.Item1.TryGetTarget(out _));Assert.False(weak.Item2.TryGetTarget(out _));
Assert.Equal(0,map.Count);Assert.Equal(1,map.HandlesCreated);Assert.Equal(1,map.HandlesDisposed);
map.Clear();map.Clear();Assert.Equal(1,map.HandlesDisposed);
var key=new Key();var live=AddWithAliveKey(map,key);Collect();
Assert.True(live.TryGetTarget(out var value));Assert.Same(value,map[key]);Assert.Same(key,value!.BackReference);
Assert.True(map.Remove(key));Assert.False(map.Remove(key));Assert.Equal(2,map.HandlesDisposed);GC.KeepAlive(key);
}
[Fact]
public void DetachedJavascriptReferencesKeepIdentityListenersAndCanReattachAfterCollection()
{
var runtime=new JavaScriptRuntime();var document=new HtmlParser().ParseDocument("<body></body>");
var dom=new DomBridge(runtime,document,new("http://lifetime.test/"),new());
Assert.True(runtime.Evaluate("globalThis.saved=document.createElement('button');saved.id='Saved';document.body.append(saved);saved.remove();globalThis.clicked=0;saved.addEventListener('click',()=>clicked++);globalThis.originalId=saved.__nodeId;'ready'").Success);
Collect();
Assert.Equal("true",runtime.Evaluate("document.body.append(saved);saved.click();document.getElementById('Saved')===saved&&saved.__nodeId===originalId&&clicked===1").Text);
int identity=int.Parse(runtime.Evaluate("saved.__nodeId").Text);
Assert.Same(document.GetElementById("Saved"),dom.ResolveNode(identity));
dom.ReleaseRetainedState();Assert.Equal(0,dom.Lifetime.OutstandingHandles);GC.KeepAlive(runtime);
}
[Fact]
public void DetachedDomCanvasFormAndListenerCyclesDoNotRemainPageRoots()
{
var runtime=new JavaScriptRuntime();var document=new HtmlParser().ParseDocument("<body></body>");
var dom=new DomBridge(runtime,document,new("http://lifetime.test/"),new());
var created=runtime.Evaluate("for(let i=0;i<20;i++){let c=document.createElement('canvas');c.width=4;c.height=4;document.body.append(c);c.getContext('2d').fillRect(0,0,2,2);c.remove();let a=document.createElement('textarea');a.value='temporary';a.setSelectionRange(1,2);document.body.append(a);a.remove();let n=document.createElement('span');n.textContent='gone';n.addEventListener('click',(function(node){return()=>node.textContent})(n));document.body.append(n);n.remove();c=null;a=null;n=null;} 'done'");
Assert.True(created.Success,created.Error);
Assert.True(runtime.Evaluate("document.body.textContent='';void 0").Success);
var before=dom.Lifetime;Collect();var after=dom.Lifetime;
output.WriteLine("Detached before={0}; after={1}",before,after);
Assert.InRange(after.Wrappers,1,12);Assert.InRange(after.Identities,1,15);
Assert.Equal(0,after.Canvases);Assert.Equal(0,after.FormSelections);
Assert.True(after.HandlesDisposed>100);Assert.True(after.OutstandingHandles<before.OutstandingHandles/4);
dom.ReleaseRetainedState();Assert.Equal(dom.Lifetime.HandlesCreated,dom.Lifetime.HandlesDisposed);GC.KeepAlive(runtime);
}
[Fact]
public void OriginalMonacoHelloCSharpIdleKeepsLiveDomAndMemoryBoundedWhileTimersContinue()
{
var storage=new MemoryWorkspace();string source=storage.ReadText(SystemPaths.Combine(SystemPaths.Documents,"Hello.cs"));
var browser=new BrowserService();browser.ResizeViewport(796,404);browser.NavigateEditor(source,"Hello.cs");
long baseline=0;long firstRevision=0;BrowserDomLifetime? first=null;
for(int step=0;step<=200;step++)
{
browser.AdvanceTime(step*500);browser.Layout(new(796,404));browser.ReadMonacoProof();
if(step%25!=0)continue;
Collect();var lifetime=browser.ReadLifetimeDiagnostics()!;long memory=GC.GetTotalMemory(true);
var weak=browser.ReadWeakCollectionLifetime()!;
output.WriteLine("JS weak tables={0}, entries={1}, created={2}, disposed={3}",weak.Tables,weak.Entries,weak.Created,weak.Disposed);
if(step>=50)Assert.InRange(weak.Created-weak.Disposed,0,4096);
output.WriteLine("Step {0}: bytes={1}, wrappers={2}, identities={3}, created={4}, disposed={5}, layout={6}, revision={7}",
step,memory,lifetime.Wrappers,lifetime.Identities,lifetime.HandlesCreated,lifetime.HandlesDisposed,browser.LayoutPassCount,browser.Revision);
if(step==50){baseline=memory;firstRevision=browser.Revision;first=lifetime;}
if(step>=50)
{
Assert.InRange(lifetime.Wrappers,1,400);Assert.InRange(lifetime.Identities,1,500);
Assert.InRange(lifetime.OutstandingHandles,1,1024);Assert.InRange(memory-baseline,-8_388_608,8_388_608);
}
}
var last=browser.ReadLifetimeDiagnostics()!;
Assert.True(browser.Revision>firstRevision+100);Assert.True(browser.LayoutPassCount>100);
// With a working innerText setter Monaco can measure its nonbreaking
// space probe once. It no longer retries zero-width font measurements
// throughout idle, so disposal churn itself is not a lifetime invariant.
Assert.InRange(last.OutstandingHandles-first!.OutstandingHandles,-1024,32);
Assert.Equal(source,browser.ReadEditorText());
browser.Release();Assert.Null(browser.Document);GC.KeepAlive(browser);
}
[Fact]
public void ReadyActorRetriesRejectedRetirementAndReleasesOnlyOnItsWorker()
{
var scheduler=new Scheduler();var factory=new Factory(scheduler);long clock=0;
var service=new BrowserEditorPreparationService(factory,scheduler,new CanvasFactory(),threadIdentity:()=>scheduler.Running?2:1,monotonicMilliseconds:()=>++clock);
var operation=service.Begin(new(1,1,"const answer=42;","Hello.js",796,404));scheduler.RunAll();
Assert.True(operation.TryTakeResult(out var result));Assert.Null(result.Error);var actor=Assert.IsType<BackgroundBrowserEditorSession>(result.Page);
var backend=factory.Page!;Assert.NotNull(backend.Document);
scheduler.Reject=true;actor.Cancel();Assert.False(actor.IsRetired);actor.AdvanceTime(++clock);Assert.False(actor.IsRetired);
Assert.NotNull(backend.Document);scheduler.Reject=false;actor.AdvanceTime(++clock);scheduler.RunAll();
Assert.True(actor.IsRetired);Assert.Null(backend.Document);Assert.Equal(0,scheduler.WorkerEnqueues);Assert.True(factory.ReleaseObservedOnWorker);
}
[Fact]
public void CancelledPublishedOperationRetainsCleanupUntilQueueAcceptsIt()
{
var scheduler=new Scheduler();var factory=new Factory(scheduler);long clock=0;
var service=new BrowserEditorPreparationService(factory,scheduler,new CanvasFactory(),monotonicMilliseconds:()=>++clock);
var operation=service.Begin(new(1,1,"const answer=42;","Hello.js",796,404));scheduler.RunAll();
Assert.NotNull(factory.Page!.Document);
scheduler.Reject=true;operation.Cancel();Assert.False(operation.TryTakeResult(out _));Assert.False(operation.IsRetired);
operation.AdvanceRetirement(++clock);Assert.False(operation.IsRetired);Assert.NotNull(factory.Page!.Document);
scheduler.Reject=false;operation.AdvanceRetirement(++clock);scheduler.RunAll();operation.AdvanceRetirement(++clock);
Assert.True(operation.IsRetired);Assert.Null(factory.Page.Document);Assert.Equal(0,scheduler.WorkerEnqueues);
}
[Fact]
public void CancellationAtPreparedPublicationBoundaryReleasesOwnedBackendWithoutWorkerEnqueue()
{
var scheduler=new Scheduler();var factory=new Factory(scheduler);IEditorPreparationOperation? operation=null;bool prepared=false;
var service=new BrowserEditorPreparationService(factory,scheduler,new CanvasFactory(),line=>
{if(line.StartsWith("XFE_EDITOR_BACKGROUND|Prepared|",StringComparison.Ordinal)){prepared=true;operation!.Cancel();}});
operation=service.Begin(new(1,1,"const answer=42;","Hello.js",796,404));scheduler.RunAll();
Assert.True(prepared);Assert.False(operation.TryTakeResult(out _));Assert.True(operation.IsRetired);Assert.Null(factory.Page!.Document);
Assert.True(factory.ReleaseObservedOnWorker);Assert.Equal(0,scheduler.WorkerEnqueues);
}
private sealed class Scheduler:IEditorBackgroundWorkScheduler
{
private readonly Queue<Action> queue=[];public bool Reject,Running;public int WorkerEnqueues;
public bool TryQueue(Action work){if(Running){WorkerEnqueues++;throw new InvalidOperationException("The scheduler has one UI producer.");}if(Reject)return false;queue.Enqueue(work);return true;}
public void RunAll(){int count=0;while(queue.TryDequeue(out var work)){Assert.True(++count<20);Running=true;try{work();}finally{Running=false;}}}
}
private sealed class Factory(Scheduler scheduler):IInteractiveBrowserFactory
{
public BrowserService? Page;public bool ReleaseObservedOnWorker;
public IInteractiveBrowserSession CreatePageSession()=>Page=new(){Trace=line=>{if(line.StartsWith("XFE_BROWSER_RELEASE|",StringComparison.Ordinal)){Assert.True(scheduler.Running);Assert.Contains("|HandlesAfter|0",line,StringComparison.Ordinal);Assert.Contains("XFE_BROWSER_WEAK_RELEASE|",line,StringComparison.Ordinal);Assert.Contains("|TablesAfter|0|EntriesAfter|0|",line,StringComparison.Ordinal);ReleaseObservedOnWorker=true;}}};
}
private sealed class CanvasFactory:IEditorPreparationCanvasFactory{public IDesktopCanvas CreateEditorPreparationCanvas()=>new Canvas();}
private sealed class Canvas:IDesktopWebCanvas
{
public int Width=>796;public int Height=>404;
public void Fill(uint rgb,int x,int y,int w,int h){}public void Line(uint rgb,int x1,int y1,int x2,int y2){}
public void Text(string text,uint rgb,int x,int y){}public void Present(){}public void PushClip(DesktopBounds clip){}public void PopClip(){}
public double MeasureWebText(string text,double size,string family,int weight)=>new BrowserFallbackTextMetrics().Measure(text,new(size,family,weight));
public (int Width,int Height,byte[] Alpha) RasterizeWebText(string text,double size,string family,int weight)=>(0,0,[]);
public void WebText(string text,uint color,double x,double y,double size,string family,int weight,double opacity=1){}
}
}