V17 / d6d373561692
GuiSession.cs
Interfaces/Gui/LilithGui/GuiSession.cs
Unchanged from the preceding versionCS
using System.Diagnostics;
using System.Text.Json;
namespace Lilith.Gui;
internal sealed record GuiEvent(long Sequence,string Kind,JsonElement Data);
internal sealed class GuiSession(string repo,string? stateRoot=null) : IAsyncDisposable
{
private readonly object _sync=new();
private readonly List<GuiEvent> _events=[];
private readonly Dictionary<string,TaskCompletionSource<JsonElement>> _pending=[];
private readonly Dictionary<string,string> _images=new(StringComparer.Ordinal);
private readonly SemaphoreSlim _lifecycle=new(1,1);
private readonly SemaphoreSlim _writes=new(1,1);
private Process? _worker;
private long _sequence;
private JsonElement? _ready,_status;
internal Func<JsonElement,Task<object>>? BrowserRequest {get;set;}
internal GuiVersion? Version {get;private set;}
internal void Emit(string kind,object data)
{
var element=JsonSerializer.SerializeToElement(data);
lock(_sync)
{
if(kind=="reset"){_ready=null;_images.Clear();}
if(kind=="ready")_ready=element;
if(kind=="status")_status=element;
if(kind=="images"&&element.ValueKind==JsonValueKind.Array)
foreach(var image in element.EnumerateArray())
{
string? hash=image.EnumerateObject().FirstOrDefault(p=>p.Name.Equals("sha256",StringComparison.OrdinalIgnoreCase)).Value.GetString();
string? file=image.EnumerateObject().FirstOrDefault(p=>p.Name.Equals("file",StringComparison.OrdinalIgnoreCase)).Value.GetString();
if(hash is not null&&file is not null)_images[hash]=file;
}
while(_images.Count>100)_images.Remove(_images.Keys.First());
_events.Add(new(++_sequence,kind,element));if(_events.Count>2000)_events.RemoveRange(0,_events.Count-2000);
}
}
internal byte[] ReadImage(string hash)
{
string file;lock(_sync)file=_images.GetValueOrDefault(hash)??throw new FileNotFoundException("Unknown image artifact.");
string path=Path.GetFullPath(file),root=Path.GetFullPath(Path.Combine(repo,"Admin","GuiState"))+Path.DirectorySeparatorChar;
if(!path.StartsWith(root,StringComparison.OrdinalIgnoreCase))throw new IOException("Image is outside GUI-owned state.");
string? check=path;
while(check is not null&&check.StartsWith(root,StringComparison.OrdinalIgnoreCase))
{if((File.GetAttributes(check)&FileAttributes.ReparsePoint)!=0)throw new IOException("Linked artifacts are not served.");check=Path.GetDirectoryName(check);}
if(new FileInfo(path).Length>16000000)throw new IOException("Oversized image.");byte[] bytes=File.ReadAllBytes(path);
if(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes))!=hash)throw new IOException("Image changed since capture.");
return bytes;
}
internal GuiEvent[] Events(long after){lock(_sync)return _events.Where(e=>e.Sequence>after).Take(500).ToArray();}
internal object Summary(){lock(_sync)return new{version=Version,ready=_ready,status=_status,sequence=_sequence};}
internal async Task<JsonElement> BootAsync(int major,JsonElement settings)
{
await _lifecycle.WaitAsync();
try
{
await StopAsync();
var catalog=new VersionCatalog(repo);var version=(await catalog.ListAsync()).Single(v=>v.Major==major);
Version=version;
Emit("reset",new{version});
string backend=await catalog.PrepareAsync(version,text=>Emit("status",new{state="starting",text}));
string workerProject=Path.Combine(repo,"Interfaces","Gui","LilithGui.Worker","LilithGui.Worker.csproj");
await VersionCatalog.Run("dotnet",["build",workerProject,"-c","Release","-p:NuGetAudit=false"],repo);
string worker=Path.Combine(repo,"Admin","Build","bin","Interfaces","Gui","LilithGui.Worker","LilithGui.Worker","Release","net10.0","LilithGui.Worker.dll");
var start=new ProcessStartInfo("dotnet"){WorkingDirectory=backend,RedirectStandardInput=true,RedirectStandardOutput=true,RedirectStandardError=true,UseShellExecute=false,CreateNoWindow=true};
start.ArgumentList.Add(worker);start.ArgumentList.Add(backend);
string state=Path.Combine(stateRoot??Path.Combine(repo,"Admin","GuiState"),"v"+major);Directory.CreateDirectory(state);
start.ArgumentList.Add(state);start.Environment["AGENT_CORE_CHAT_HISTORY_ROOT"]=Path.Combine(state,"History");
if(BrowserRequest is not null)start.Environment["LILITH_DESKTOP_BROWSER"]="1";
start.Environment["LILITH_SCENARIOS_ROOT"]=Path.Combine(backend,"Scenarios");
// Historical localhost probes have a two-second IPv6 fallback timeout.
// This affects only the isolated worker, not the user's/global network settings.
start.Environment["DOTNET_SYSTEM_NET_DISABLEIPV6"]="1";
_worker=Process.Start(start)??throw new IOException("Backend worker failed to start.");Version=version;
_=ReadAsync(_worker);_=ReadErrors(_worker);
return await SendAsync("boot",settings);
}
finally{_lifecycle.Release();}
}
private async Task ReadAsync(Process process)
{
while(await process.StandardOutput.ReadLineAsync() is {} line)
{
if(!line.StartsWith("@lilith-gui ",StringComparison.Ordinal))continue;
try
{
using var doc=JsonDocument.Parse(line[12..]);var root=doc.RootElement;string kind=root.GetProperty("kind").GetString()!;var data=root.GetProperty("data").Clone();
if(kind=="browser_request"&&BrowserRequest is not null){_=HandleBrowserAsync(process,data);continue;}
if(kind=="reply")
{
string id=data.GetProperty("id").GetString()!;TaskCompletionSource<JsonElement>? completion;
lock(_sync){_pending.Remove(id,out completion);}
if(data.TryGetProperty("error",out var error))completion?.TrySetException(new InvalidOperationException(error.GetString()));else completion?.TrySetResult(data.GetProperty("result").Clone());
}
else Emit(kind,data);
}
catch(JsonException){Emit("trace",new{sender="GUI",text="Invalid backend event was ignored."});}
}
if(ReferenceEquals(_worker,process))
lock(_sync){_ready=null;foreach(var pending in _pending.Values)pending.TrySetException(new IOException("Backend process closed."));_pending.Clear();}
}
private async Task ReadErrors(Process process){while(await process.StandardError.ReadLineAsync() is {} line)Emit("trace",new{sender="Backend",text=line[..Math.Min(2000,line.Length)]});}
private async Task HandleBrowserAsync(Process owner,JsonElement data)
{
object payload;
try{payload=new{requestId=data.GetProperty("requestId").GetString(),result=await BrowserRequest!(data)};}
catch(Exception ex){payload=new{requestId=data.GetProperty("requestId").GetString(),error=ex.Message};}
await _writes.WaitAsync();
try{if(ReferenceEquals(_worker,owner)&&!owner.HasExited){await owner.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new{id="browser",action="browser_result",payload}));await owner.StandardInput.FlushAsync();}}
finally{_writes.Release();}
}
internal async Task<JsonElement> SendAsync(string action,JsonElement payload)
{
if(_worker is null||_worker.HasExited)throw new InvalidOperationException("Start a version first.");
string id=Guid.NewGuid().ToString("N");var completion=new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
lock(_sync)_pending[id]=completion;
await _writes.WaitAsync();
try{await _worker.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new{id,action,payload}));await _worker.StandardInput.FlushAsync();}
finally{_writes.Release();}
return await completion.Task.WaitAsync(TimeSpan.FromMinutes(45));
}
private async Task StopAsync()
{
if(_worker is null)return;
if(!_worker.HasExited)
{
try{await SendAsync("stop",JsonSerializer.SerializeToElement(new{})).WaitAsync(TimeSpan.FromSeconds(15));await _worker.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5));}
catch(Exception){if(!_worker.HasExited)_worker.Kill(true);}
}
_worker.Dispose();_worker=null;Version=null;
}
public async ValueTask DisposeAsync()=>await StopAsync();
}