Created
April 17, 2026 03:18
-
-
Save DamianEdwards/be1ab7a28fc0093e97b6b464a2e27a4e to your computer and use it in GitHub Desktop.
Repro for mitchdenny/hex1b#273
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env dotnet | |
| #:property SuppressTrimAnalysisWarnings=false | |
| #:property TrimmerSingleWarn=false | |
| #:package System.CommandLine@2.0.6 | |
| #:package Spectre.Console@0.55.0 | |
| #:package Hex1b@0.128.0 | |
| using System.CommandLine; | |
| using System.ComponentModel; | |
| using System.Diagnostics; | |
| using System.Diagnostics.CodeAnalysis; | |
| using System.Globalization; | |
| using System.IO.Pipelines; | |
| using System.Net.Sockets; | |
| using System.Runtime.CompilerServices; | |
| using System.Security.Cryptography; | |
| using System.Text; | |
| using System.Text.Json; | |
| using System.Text.Json.Serialization; | |
| using Hex1b; | |
| using Hex1b.Automation; | |
| using Hex1b.Diagnostics; | |
| using Hex1b.Input; | |
| using Hex1b.Tokens; | |
| using Hex1b.Widgets; | |
| using Spectre.Console; | |
| const string TargetCommand = "copilot"; | |
| const string TargetPrompt = "We're just testing remote sessions here in the context of a proof regarding the spawning and management of background instances of `copilot`. Stay running and await further instructions."; | |
| const string TargetWorkingDirectory = @"D:\src\GitHub\DamianEdwards\kusto-cli"; | |
| const string DotnetHost = "dotnet"; | |
| const int MaxTrackedInstances = 5; | |
| const string ControllerLogFileName = "hex1b-daemon.log"; | |
| const int WorkloadLogTailCharacterLimit = 4000; | |
| TimeSpan JoinSocketReadyTimeout = TimeSpan.FromSeconds(30); | |
| string TargetArguments = $"--yolo --remote -i \"{TargetPrompt}\""; | |
| string[] TargetArgumentList = ["--yolo", "--remote", "-i", TargetPrompt]; | |
| TimeSpan SecondaryCtrlCDelay = TimeSpan.FromSeconds(1); | |
| TimeSpan StartupTrackingDelay = TimeSpan.FromSeconds(3); | |
| TimeSpan GracefulShutdownTimeout = TimeSpan.FromSeconds(15); | |
| if (!OperatingSystem.IsWindows()) | |
| { | |
| AnsiConsole.MarkupLine("[red]This sample is only supported on Windows.[/]"); | |
| return 1; | |
| } | |
| return await BuildRootCommand().Parse(args).InvokeAsync(); | |
| RootCommand BuildRootCommand() | |
| { | |
| RootCommand root = new("Daemon-style controller for copilot sessions hosted through Hex1b PTY terminals."); | |
| Option<bool> backgroundOption = new("--background") | |
| { | |
| Description = "Run without keyboard input.", | |
| Hidden = true, | |
| }; | |
| Command runCommand = new("run", "Run the controller in the foreground and listen for keyboard input."); | |
| runCommand.Add(backgroundOption); | |
| runCommand.SetAction((parseResult, cancellationToken) => RunAsync(parseResult.GetValue(backgroundOption), cancellationToken)); | |
| root.Add(runCommand); | |
| Command startCommand = new("start", "Start a background run instance for the current directory."); | |
| startCommand.SetAction((_, cancellationToken) => StartAsync(cancellationToken)); | |
| root.Add(startCommand); | |
| Command stopCommand = new("stop", "Stop the background run instance for the current directory."); | |
| stopCommand.SetAction((_, cancellationToken) => StopAsync(cancellationToken)); | |
| root.Add(stopCommand); | |
| Command joinCommand = new("join", "Join the running background instance in the current terminal."); | |
| joinCommand.SetAction((_, cancellationToken) => JoinAsync(cancellationToken)); | |
| root.Add(joinCommand); | |
| root.SetAction(_ => | |
| { | |
| RenderBanner(); | |
| AnsiConsole.MarkupLine("[yellow]Choose one of:[/] [aqua]run[/], [aqua]start[/], [aqua]join[/], or [aqua]stop[/]."); | |
| return 1; | |
| }); | |
| return root; | |
| } | |
| async Task<int> RunAsync(bool backgroundMode, CancellationToken cancellationToken) | |
| { | |
| string currentDirectory = Path.GetFullPath(Environment.CurrentDirectory); | |
| string pidFilePath = GetPidFilePath(currentDirectory); | |
| string controllerLogPath = GetControllerLogPath(currentDirectory); | |
| string stopEventName = GetStopEventName(currentDirectory); | |
| using SingleInstanceLock? runLock = TryAcquireRunLock(currentDirectory); | |
| if (runLock is null) | |
| { | |
| AnsiConsole.MarkupLine($"[red]A run instance is already active for[/] [aqua]{Escape(currentDirectory)}[/]."); | |
| AnsiConsole.MarkupLine("[yellow]Use[/] [aqua]dotnet hex1bapp.cs -- stop[/] [yellow]to stop it first.[/]"); | |
| return 1; | |
| } | |
| CleanupStalePidFile(currentDirectory, pidFilePath); | |
| using EventWaitHandle stopEvent = new(false, EventResetMode.AutoReset, stopEventName); | |
| RenderBanner(); | |
| AnsiConsole.MarkupLine($"[green]Hex1b controller running in[/] [aqua]{Escape(currentDirectory)}[/]."); | |
| AnsiConsole.MarkupLine($"[grey]Controller log:[/] [aqua]{Escape(controllerLogPath)}[/]"); | |
| AppendControllerLog(controllerLogPath, $"Controller started in {currentDirectory} (backgroundMode={backgroundMode})."); | |
| AppendControllerLog(controllerLogPath, $"Target command: {TargetCommand} {TargetArguments}"); | |
| AppendControllerLog(controllerLogPath, $"Target working directory: {TargetWorkingDirectory}"); | |
| if (backgroundMode) | |
| { | |
| AnsiConsole.MarkupLine("[grey]Background mode is active. Use[/] [aqua]dotnet hex1bapp.cs -- stop[/] [grey]to stop this controller.[/]"); | |
| } | |
| else | |
| { | |
| AnsiConsole.MarkupLine($"[grey]Press[/] [aqua]S[/] [grey]to start[/] [aqua]{Escape(TargetCommand)} {Escape(TargetArguments)}[/] [grey]in[/] [aqua]{Escape(TargetWorkingDirectory)}[/][grey],[/] [aqua]1[/]-[aqua]5[/] [grey]to stop an instance, or[/] [aqua]Ctrl+C[/] [grey]to stop everything and exit.[/]"); | |
| } | |
| List<TrackedSession> trackedSessions = []; | |
| int nextSessionToken = 1; | |
| using CancellationTokenSource keyboardCts = new(); | |
| using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, keyboardCts.Token); | |
| using CancellationTokenSource stopMonitorCts = new(); | |
| int shutdownRequested = 0; | |
| void RequestShutdown(string message) | |
| { | |
| if (Interlocked.Exchange(ref shutdownRequested, 1) != 0) | |
| { | |
| return; | |
| } | |
| AppendControllerLog(controllerLogPath, $"Shutdown requested: {Markup.Remove(message)}"); | |
| AnsiConsole.MarkupLine(message); | |
| keyboardCts.Cancel(); | |
| } | |
| Task stopSignalTask = Task.Run(() => | |
| { | |
| int signaledIndex = WaitHandle.WaitAny([stopEvent, stopMonitorCts.Token.WaitHandle]); | |
| if (signaledIndex == 0) | |
| { | |
| RequestShutdown("[yellow]Stop signal received. Stopping tracked instances before exit...[/]"); | |
| } | |
| }, CancellationToken.None); | |
| ConsoleCancelEventHandler? cancelHandler = null; | |
| cancelHandler = (_, eventArgs) => | |
| { | |
| eventArgs.Cancel = true; | |
| RequestShutdown("[yellow]Ctrl+C received. Stopping tracked instances before exit...[/]"); | |
| }; | |
| Console.CancelKeyPress += cancelHandler; | |
| try | |
| { | |
| if (backgroundMode) | |
| { | |
| try | |
| { | |
| TrackedSession session = await StartTrackedSessionAsync(nextSessionToken++, controllerLogPath, cancellationToken, enableDiagnostics: true); | |
| trackedSessions.Add(session); | |
| AnsiConsole.MarkupLine($"[green]Auto-started instance[/] [aqua]1[/] [green]for background mode.[/]"); | |
| AnsiConsole.MarkupLine($"[grey]Workload log:[/] [aqua]{Escape(session.WorkloadLogPath)}[/]"); | |
| AppendControllerLog(controllerLogPath, $"Auto-started session token {session.SessionToken}. Workload log: {session.WorkloadLogPath}"); | |
| AppendControllerLog(controllerLogPath, $"Attach socket: {GetDiagnosticsSocketPath(Environment.ProcessId)}"); | |
| } | |
| catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or DirectoryNotFoundException) | |
| { | |
| AnsiConsole.MarkupLine($"[red]Failed to auto-start {Escape(TargetCommand)}:[/] {Escape(ex.Message)}"); | |
| AppendControllerLog(controllerLogPath, $"Failed to auto-start {TargetCommand}: {ex}"); | |
| return 1; | |
| } | |
| RenderTrackedSessions(trackedSessions); | |
| try | |
| { | |
| while (!linkedCts.IsCancellationRequested) | |
| { | |
| await ReconcileExitedSessionsAsync(trackedSessions, controllerLogPath, echoToConsole: false); | |
| await Task.Delay(TimeSpan.FromSeconds(1), linkedCts.Token); | |
| } | |
| } | |
| catch (OperationCanceledException) when (keyboardCts.IsCancellationRequested) | |
| { | |
| } | |
| } | |
| else | |
| { | |
| RenderTrackedSessions(trackedSessions); | |
| while (!linkedCts.IsCancellationRequested) | |
| { | |
| await ReconcileExitedSessionsAsync(trackedSessions, controllerLogPath, echoToConsole: true); | |
| ConsoleKeyInfo? keyInfo; | |
| try | |
| { | |
| keyInfo = await AnsiConsole.Console.Input.ReadKeyAsync(true, linkedCts.Token); | |
| } | |
| catch (OperationCanceledException) when (keyboardCts.IsCancellationRequested) | |
| { | |
| break; | |
| } | |
| await ReconcileExitedSessionsAsync(trackedSessions, controllerLogPath, echoToConsole: true); | |
| if (keyInfo is null) | |
| { | |
| continue; | |
| } | |
| ConsoleKeyInfo key = keyInfo.Value; | |
| char keyChar = key.KeyChar; | |
| if (char.ToUpperInvariant(keyChar) == 'S') | |
| { | |
| if (trackedSessions.Count >= MaxTrackedInstances) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]Already tracking the maximum of {MaxTrackedInstances} instances.[/]"); | |
| RenderTrackedSessions(trackedSessions); | |
| continue; | |
| } | |
| try | |
| { | |
| TrackedSession session = await StartTrackedSessionAsync(nextSessionToken++, controllerLogPath, cancellationToken, enableDiagnostics: false); | |
| trackedSessions.Add(session); | |
| AnsiConsole.MarkupLine($"[green]Started instance[/] [aqua]{trackedSessions.Count}[/] [grey](session token {session.SessionToken})[/]."); | |
| AnsiConsole.MarkupLine($"[grey]Workload log:[/] [aqua]{Escape(session.WorkloadLogPath)}[/]"); | |
| AppendControllerLog(controllerLogPath, $"Started tracked session token {session.SessionToken}. Workload log: {session.WorkloadLogPath}"); | |
| } | |
| catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or DirectoryNotFoundException) | |
| { | |
| AnsiConsole.MarkupLine($"[red]Failed to start {Escape(TargetCommand)}:[/] {Escape(ex.Message)}"); | |
| AppendControllerLog(controllerLogPath, $"Failed to start {TargetCommand}: {ex}"); | |
| } | |
| RenderTrackedSessions(trackedSessions); | |
| continue; | |
| } | |
| if (char.IsDigit(keyChar)) | |
| { | |
| int ordinal = keyChar - '0'; | |
| if (ordinal < 1 || ordinal > trackedSessions.Count) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]No tracked instance matches key[/] [aqua]{ordinal}[/]."); | |
| RenderTrackedSessions(trackedSessions); | |
| continue; | |
| } | |
| TrackedSession session = trackedSessions[ordinal - 1]; | |
| AppendControllerLog(controllerLogPath, $"User requested shutdown of instance {ordinal} (session token {session.SessionToken})."); | |
| StopProcessOutcome outcome = await StopTrackedSessionAsync(session, $"instance {ordinal}", cancellationToken, controllerLogPath); | |
| trackedSessions.RemoveAt(ordinal - 1); | |
| if (outcome == StopProcessOutcome.AlreadyExited) | |
| { | |
| AnsiConsole.MarkupLine($"[grey]Instance {ordinal} was already stopped.[/]"); | |
| } | |
| RenderTrackedSessions(trackedSessions); | |
| continue; | |
| } | |
| if (key.Key == ConsoleKey.Escape) | |
| { | |
| AnsiConsole.MarkupLine("[grey]Use Ctrl+C to shut down the controller and all tracked instances.[/]"); | |
| continue; | |
| } | |
| AnsiConsole.MarkupLine($"[grey]Ignored key[/] [aqua]{Escape(DescribeKey(key))}[/][grey].[/]"); | |
| } | |
| } | |
| } | |
| finally | |
| { | |
| Console.CancelKeyPress -= cancelHandler; | |
| stopMonitorCts.Cancel(); | |
| await stopSignalTask; | |
| await ReconcileExitedSessionsAsync(trackedSessions, controllerLogPath, echoToConsole: false); | |
| if (trackedSessions.Count > 0) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]Stopping {trackedSessions.Count} tracked instance(s)...[/]"); | |
| AppendControllerLog(controllerLogPath, $"Stopping {trackedSessions.Count} tracked session(s) during controller shutdown."); | |
| await Task.WhenAll(trackedSessions.Select(session => StopTrackedSessionAsync(session, $"session {session.SessionToken}", CancellationToken.None, controllerLogPath))); | |
| trackedSessions.Clear(); | |
| } | |
| CleanupOwnedPidFile(currentDirectory, pidFilePath); | |
| AppendControllerLog(controllerLogPath, "Controller stopped."); | |
| RenderTrackedSessions(trackedSessions); | |
| AnsiConsole.MarkupLine("[green]Controller stopped.[/]"); | |
| } | |
| return 0; | |
| } | |
| async Task<int> StartAsync(CancellationToken cancellationToken) | |
| { | |
| string currentDirectory = Path.GetFullPath(Environment.CurrentDirectory); | |
| string pidFilePath = GetPidFilePath(currentDirectory); | |
| if (TryReadDaemonState(pidFilePath, out DaemonState? existingState) && | |
| TryGetVerifiedProcess(existingState!, out Process? existingProcess)) | |
| { | |
| using (existingProcess) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]A background controller is already running for[/] [aqua]{Escape(currentDirectory)}[/] [grey](PID {existingState!.Pid})[/]."); | |
| return 1; | |
| } | |
| } | |
| CleanupStalePidFile(currentDirectory, pidFilePath); | |
| SelfLaunchSpec selfLaunchSpec = GetSelfLaunchSpec(); | |
| ProcessStartInfo startInfo = new() | |
| { | |
| FileName = selfLaunchSpec.FileName, | |
| WorkingDirectory = currentDirectory, | |
| UseShellExecute = false, | |
| CreateNoWindow = true, | |
| RedirectStandardOutput = true, | |
| RedirectStandardError = true, | |
| }; | |
| foreach (string argument in selfLaunchSpec.ArgumentPrefix) | |
| { | |
| startInfo.ArgumentList.Add(argument); | |
| } | |
| startInfo.ArgumentList.Add("run"); | |
| startInfo.ArgumentList.Add("--background"); | |
| using Process process = Process.Start(startInfo) | |
| ?? throw new InvalidOperationException("Failed to start the background controller process."); | |
| await Task.Delay(500, cancellationToken); | |
| if (process.HasExited) | |
| { | |
| string output = (await process.StandardOutput.ReadToEndAsync(cancellationToken)).Trim(); | |
| string error = (await process.StandardError.ReadToEndAsync(cancellationToken)).Trim(); | |
| string details = string.Join(Environment.NewLine, new[] { output, error }.Where(value => !string.IsNullOrWhiteSpace(value))); | |
| if (string.IsNullOrWhiteSpace(details)) | |
| { | |
| details = "The background controller exited before it could finish starting."; | |
| } | |
| AnsiConsole.MarkupLine($"[red]{Escape(details)}[/]"); | |
| return 1; | |
| } | |
| DaemonState state = new() | |
| { | |
| Pid = process.Id, | |
| StartedAtUtc = process.StartTime.ToUniversalTime(), | |
| }; | |
| WriteDaemonState(pidFilePath, state); | |
| AnsiConsole.MarkupLine($"[green]Started background controller[/] [grey](PID {process.Id})[/] [green]for[/] [aqua]{Escape(currentDirectory)}[/]."); | |
| AnsiConsole.MarkupLine($"[grey]State file:[/] [aqua]{Escape(pidFilePath)}[/]"); | |
| return 0; | |
| } | |
| async Task<int> StopAsync(CancellationToken cancellationToken) | |
| { | |
| string currentDirectory = Path.GetFullPath(Environment.CurrentDirectory); | |
| string pidFilePath = GetPidFilePath(currentDirectory); | |
| if (!TryReadDaemonState(pidFilePath, out DaemonState? state)) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]No pid.json was found in[/] [aqua]{Escape(currentDirectory)}[/]."); | |
| return 1; | |
| } | |
| if (!TryGetVerifiedProcess(state!, out Process? process)) | |
| { | |
| CleanupStalePidFile(currentDirectory, pidFilePath); | |
| AnsiConsole.MarkupLine("[yellow]The recorded background controller is no longer running. Removed stale pid.json.[/]"); | |
| return 1; | |
| } | |
| using (process) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]Sending stop signal to background controller[/] [grey](PID {state!.Pid})[/][yellow]...[/]"); | |
| AppendControllerLog(GetControllerLogPath(currentDirectory), $"Stop command requested shutdown of background controller PID {state.Pid}."); | |
| bool signalSent = SignalStopEvent(currentDirectory); | |
| if (!signalSent) | |
| { | |
| AnsiConsole.MarkupLine("[yellow]The controller stop signal was unavailable. Waiting for exit before forcing a kill.[/]"); | |
| } | |
| bool exitedGracefully = await WaitForExitAsync(process, GracefulShutdownTimeout, cancellationToken); | |
| if (!exitedGracefully) | |
| { | |
| AnsiConsole.MarkupLine("[red]Background controller did not exit within 15 seconds; killing it.[/]"); | |
| process.Kill(entireProcessTree: true); | |
| await process.WaitForExitAsync(cancellationToken); | |
| } | |
| } | |
| DeletePidFileIfPresent(pidFilePath); | |
| AnsiConsole.MarkupLine("[green]Background controller stopped.[/]"); | |
| return 0; | |
| } | |
| async Task<int> JoinAsync(CancellationToken cancellationToken) | |
| { | |
| string currentDirectory = Path.GetFullPath(Environment.CurrentDirectory); | |
| string pidFilePath = GetPidFilePath(currentDirectory); | |
| if (!TryReadDaemonState(pidFilePath, out DaemonState? state)) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]No pid.json was found in[/] [aqua]{Escape(currentDirectory)}[/]."); | |
| return 1; | |
| } | |
| if (!TryGetVerifiedProcess(state!, out Process? process)) | |
| { | |
| CleanupStalePidFile(currentDirectory, pidFilePath); | |
| AnsiConsole.MarkupLine("[yellow]The recorded background controller is no longer running. Removed stale pid.json.[/]"); | |
| return 1; | |
| } | |
| using (process) | |
| { | |
| string socketPath = GetDiagnosticsSocketPath(state!.Pid); | |
| bool socketReady = await WaitForDiagnosticsSocketAsync(socketPath, JoinSocketReadyTimeout, cancellationToken); | |
| if (!socketReady) | |
| { | |
| AnsiConsole.MarkupLine($"[red]No attachable background Hex1b session became available for PID {state.Pid}.[/]"); | |
| AnsiConsole.MarkupLine($"[grey]Expected diagnostics socket:[/] [aqua]{Escape(socketPath)}[/]"); | |
| return 1; | |
| } | |
| AnsiConsole.MarkupLine("[grey]Attached. Use[/] [aqua]Ctrl+][/][grey] then[/] [aqua]D[/] [grey]to detach,[/] [aqua]L[/] [grey]to take resize leadership, or[/] [aqua]Q[/] [grey]to request shutdown of the remote session.[/]"); | |
| await using SocketAttachTransport transport = new(socketPath); | |
| await using JoinSessionClient joinSession = new(transport); | |
| return await joinSession.RunAsync(cancellationToken); | |
| } | |
| } | |
| async Task<TrackedSession> StartTrackedSessionAsync(int sessionToken, string controllerLogPath, CancellationToken cancellationToken, bool enableDiagnostics) | |
| { | |
| if (!Directory.Exists(TargetWorkingDirectory)) | |
| { | |
| throw new DirectoryNotFoundException($"The configured repo path does not exist: {TargetWorkingDirectory}"); | |
| } | |
| string workloadLogPath = CreateWorkloadLogPath(sessionToken); | |
| string sessionDiagnosticLogPath = CreateSessionDiagnosticLogPath(sessionToken); | |
| AppendSessionDiagnostic(sessionDiagnosticLogPath, $"Preparing Hex1b PTY host for {TargetCommand} at {TargetWorkingDirectory}."); | |
| Hex1bTerminal terminal = CreateTargetTerminal(workloadLogPath, enableDiagnostics); | |
| CancellationTokenSource runCts = new(); | |
| Task<TrackedSessionOutcome> completion = RunTrackedSessionAsync(sessionToken, terminal, runCts.Token, sessionDiagnosticLogPath, controllerLogPath); | |
| TrackedSession session = new(sessionToken, terminal, runCts, completion, workloadLogPath, sessionDiagnosticLogPath, DateTimeOffset.UtcNow); | |
| AppendControllerLog(controllerLogPath, $"Created Hex1b terminal for session token {sessionToken}. Workload log: {workloadLogPath}"); | |
| await Task.Delay(StartupTrackingDelay, cancellationToken); | |
| if (completion.IsCompleted) | |
| { | |
| TrackedSessionOutcome outcome = await completion; | |
| throw new InvalidOperationException(BuildStartupFailureMessage($"{TargetCommand} exited during startup.", session, outcome)); | |
| } | |
| return session; | |
| } | |
| async Task<TrackedSessionOutcome> RunTrackedSessionAsync(int sessionToken, Hex1bTerminal terminal, CancellationToken cancellationToken, string sessionDiagnosticLogPath, string controllerLogPath) | |
| { | |
| try | |
| { | |
| int exitCode = await terminal.RunAsync(cancellationToken); | |
| AppendControllerLog(controllerLogPath, $"Session token {sessionToken} exited with code {exitCode}."); | |
| AppendSessionDiagnostic(sessionDiagnosticLogPath, $"Hex1b terminal completed with exit code {exitCode}."); | |
| return new TrackedSessionOutcome(exitCode, false, null, DateTimeOffset.UtcNow); | |
| } | |
| catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) | |
| { | |
| AppendControllerLog(controllerLogPath, $"Session token {sessionToken} was cancelled."); | |
| AppendSessionDiagnostic(sessionDiagnosticLogPath, "Hex1b terminal cancellation requested."); | |
| return new TrackedSessionOutcome(null, true, null, DateTimeOffset.UtcNow); | |
| } | |
| catch (Exception ex) | |
| { | |
| AppendControllerLog(controllerLogPath, $"Session token {sessionToken} faulted: {ex}"); | |
| AppendSessionDiagnostic(sessionDiagnosticLogPath, $"Hex1b terminal faulted: {ex}"); | |
| return new TrackedSessionOutcome(null, false, ex, DateTimeOffset.UtcNow); | |
| } | |
| finally | |
| { | |
| await terminal.DisposeAsync(); | |
| } | |
| } | |
| async Task<StopProcessOutcome> StopTrackedSessionAsync(TrackedSession session, string label, CancellationToken cancellationToken, string controllerLogPath) | |
| { | |
| if (session.Completion.IsCompleted) | |
| { | |
| AppendControllerLog(controllerLogPath, $"Requested stop for {label} (session token {session.SessionToken}), but it had already exited."); | |
| AppendSessionDiagnostic(session.SessionDiagnosticLogPath, $"Controller requested stop for {label}, but the session had already exited."); | |
| return StopProcessOutcome.AlreadyExited; | |
| } | |
| int signalAttempt = 0; | |
| foreach (TimeSpan waitWindow in new[] { SecondaryCtrlCDelay, GracefulShutdownTimeout - SecondaryCtrlCDelay }) | |
| { | |
| if (waitWindow <= TimeSpan.Zero) | |
| { | |
| continue; | |
| } | |
| signalAttempt++; | |
| AppendControllerLog(controllerLogPath, $"Sending Hex1b Ctrl+C attempt {signalAttempt} to {label} (session token {session.SessionToken})."); | |
| AppendSessionDiagnostic(session.SessionDiagnosticLogPath, $"Controller sending Hex1b Ctrl+C attempt {signalAttempt} to {label}."); | |
| AnsiConsole.MarkupLine($"[yellow]Sending Ctrl+C to[/] [aqua]{Escape(label)}[/] [grey](session token {session.SessionToken})[/][yellow]...[/]"); | |
| await SendCtrlCThroughHex1bAsync(session.Terminal, cancellationToken); | |
| bool exitedGracefully = await WaitForSessionExitAsync(session, waitWindow, cancellationToken); | |
| if (exitedGracefully) | |
| { | |
| AppendControllerLog(controllerLogPath, $"Stopped {label} (session token {session.SessionToken}) gracefully."); | |
| AppendSessionDiagnostic(session.SessionDiagnosticLogPath, $"Controller observed graceful shutdown after Ctrl+C."); | |
| AnsiConsole.MarkupLine($"[green]Stopped[/] [aqua]{Escape(label)}[/] [grey](session token {session.SessionToken})[/] [green]gracefully.[/]"); | |
| return StopProcessOutcome.Graceful; | |
| } | |
| } | |
| AppendControllerLog(controllerLogPath, $"{label} (session token {session.SessionToken}) did not exit after two Ctrl+C attempts; disposing terminal."); | |
| AppendSessionDiagnostic(session.SessionDiagnosticLogPath, "Session did not exit after two Ctrl+C attempts; disposing terminal."); | |
| AnsiConsole.MarkupLine($"[red]{Escape(label)} did not exit within 15 seconds after two Ctrl+C attempts; disposing terminal.[/]"); | |
| session.RunCts.Cancel(); | |
| await session.Terminal.DisposeAsync(); | |
| await WaitForSessionExitAsync(session, TimeSpan.FromSeconds(5), CancellationToken.None); | |
| AnsiConsole.MarkupLine($"[green]Disposed[/] [aqua]{Escape(label)}[/] [grey](session token {session.SessionToken})[/]."); | |
| AppendControllerLog(controllerLogPath, $"Disposed {label} (session token {session.SessionToken}) after timeout."); | |
| return StopProcessOutcome.Killed; | |
| } | |
| async Task SendCtrlCThroughHex1bAsync(Hex1bTerminal terminal, CancellationToken cancellationToken) | |
| { | |
| Hex1bTerminalInputSequence sequence = new Hex1bTerminalInputSequenceBuilder() | |
| .Key(Hex1bKey.C, Hex1bModifiers.Control) | |
| .Build(); | |
| await sequence.ApplyAsync(terminal, cancellationToken); | |
| } | |
| static async Task<bool> WaitForSessionExitAsync(TrackedSession session, TimeSpan timeout, CancellationToken cancellationToken) | |
| { | |
| if (session.Completion.IsCompleted) | |
| { | |
| return true; | |
| } | |
| Task completedTask = await Task.WhenAny(session.Completion, Task.Delay(timeout, cancellationToken)); | |
| return completedTask == session.Completion; | |
| } | |
| static async Task<bool> WaitForExitAsync(Process process, TimeSpan timeout, CancellationToken cancellationToken) | |
| { | |
| if (process.HasExited) | |
| { | |
| return true; | |
| } | |
| using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | |
| timeoutCts.CancelAfter(timeout); | |
| try | |
| { | |
| await process.WaitForExitAsync(timeoutCts.Token); | |
| return true; | |
| } | |
| catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) | |
| { | |
| return process.HasExited; | |
| } | |
| } | |
| async Task ReconcileExitedSessionsAsync(List<TrackedSession> trackedSessions, string controllerLogPath, bool echoToConsole) | |
| { | |
| List<TrackedSession> removedSessions = []; | |
| for (int index = trackedSessions.Count - 1; index >= 0; index--) | |
| { | |
| TrackedSession session = trackedSessions[index]; | |
| if (!session.Completion.IsCompleted) | |
| { | |
| continue; | |
| } | |
| trackedSessions.RemoveAt(index); | |
| removedSessions.Add(session); | |
| } | |
| removedSessions.Reverse(); | |
| if (removedSessions.Count == 0) | |
| { | |
| return; | |
| } | |
| foreach (TrackedSession removedSession in removedSessions) | |
| { | |
| TrackedSessionOutcome outcome = await removedSession.Completion; | |
| string statusText = DescribeOutcome(outcome); | |
| AppendControllerLog(controllerLogPath, $"Tracked session token {removedSession.SessionToken} exited on its own. Outcome: {statusText}. Workload log: {removedSession.WorkloadLogPath}"); | |
| if (echoToConsole) | |
| { | |
| AnsiConsole.MarkupLine($"[yellow]Tracked instance exited on its own[/] [grey](session token {removedSession.SessionToken})[/][yellow]. Outcome:[/] {Escape(statusText)}"); | |
| AnsiConsole.MarkupLine($"[grey]Workload log:[/] [aqua]{Escape(removedSession.WorkloadLogPath)}[/]"); | |
| } | |
| string workloadLogTail = ReadWorkloadLogTail(removedSession.WorkloadLogPath); | |
| if (!string.IsNullOrWhiteSpace(workloadLogTail)) | |
| { | |
| AppendControllerLog(controllerLogPath, $"Last workload output for session token {removedSession.SessionToken}:{Environment.NewLine}{workloadLogTail}"); | |
| if (echoToConsole) | |
| { | |
| AnsiConsole.MarkupLine($"[grey]Last workload output:[/]{Environment.NewLine}{Escape(workloadLogTail)}"); | |
| } | |
| } | |
| } | |
| AppendControllerLog(controllerLogPath, $"Tracked instance count is now {trackedSessions.Count}."); | |
| if (echoToConsole) | |
| { | |
| RenderTrackedSessions(trackedSessions); | |
| } | |
| } | |
| void RenderBanner() | |
| { | |
| Panel panel = new(new Markup("[bold aqua]Hex1b Graceful Process Controller[/]")) | |
| { | |
| Border = BoxBorder.Rounded, | |
| Header = new PanelHeader("daemon-style CLI"), | |
| Expand = false, | |
| }; | |
| AnsiConsole.Write(panel); | |
| } | |
| void RenderTrackedSessions(IReadOnlyList<TrackedSession> trackedSessions) | |
| { | |
| if (trackedSessions.Count == 0) | |
| { | |
| AnsiConsole.Write(new Panel(new Markup("[grey]No tracked instances are running.[/]")) | |
| { | |
| Header = new PanelHeader("tracked instances"), | |
| Border = BoxBorder.Rounded, | |
| }); | |
| return; | |
| } | |
| Table table = new(); | |
| table.Border = TableBorder.Rounded; | |
| table.Title = new TableTitle("Tracked running instances"); | |
| table.AddColumn("#"); | |
| table.AddColumn("Session token"); | |
| table.AddColumn("Started (local)"); | |
| for (int index = 0; index < trackedSessions.Count; index++) | |
| { | |
| TrackedSession session = trackedSessions[index]; | |
| table.AddRow( | |
| (index + 1).ToString(CultureInfo.InvariantCulture), | |
| session.SessionToken.ToString(CultureInfo.InvariantCulture), | |
| session.StartedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); | |
| } | |
| AnsiConsole.Write(table); | |
| } | |
| static bool TryReadDaemonState(string pidFilePath, out DaemonState? state) | |
| { | |
| state = null; | |
| if (!File.Exists(pidFilePath)) | |
| { | |
| return false; | |
| } | |
| try | |
| { | |
| string json = File.ReadAllText(pidFilePath).Trim(); | |
| state = JsonSerializer.Deserialize(json, Hex1bAppJsonSerializerContext.Default.DaemonState); | |
| if (state is not null) | |
| { | |
| return true; | |
| } | |
| if (int.TryParse(json, NumberStyles.Integer, CultureInfo.InvariantCulture, out int legacyPid)) | |
| { | |
| state = new DaemonState | |
| { | |
| Pid = legacyPid, | |
| StartedAtUtc = default, | |
| }; | |
| return true; | |
| } | |
| return false; | |
| } | |
| catch (JsonException) | |
| { | |
| string text = File.ReadAllText(pidFilePath).Trim(); | |
| if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int legacyPid)) | |
| { | |
| state = new DaemonState | |
| { | |
| Pid = legacyPid, | |
| StartedAtUtc = default, | |
| }; | |
| return true; | |
| } | |
| return false; | |
| } | |
| } | |
| static void WriteDaemonState(string pidFilePath, DaemonState state) | |
| { | |
| string json = JsonSerializer.Serialize(state, Hex1bAppJsonSerializerContext.Default.DaemonState); | |
| File.WriteAllText(pidFilePath, json); | |
| } | |
| void CleanupStalePidFile(string currentDirectory, string pidFilePath) | |
| { | |
| if (!TryReadDaemonState(pidFilePath, out DaemonState? state)) | |
| { | |
| return; | |
| } | |
| if (TryGetVerifiedProcess(state!, out Process? process)) | |
| { | |
| process.Dispose(); | |
| return; | |
| } | |
| DeletePidFileIfPresent(pidFilePath); | |
| AnsiConsole.MarkupLine($"[grey]Removed stale pid.json from[/] [aqua]{Escape(currentDirectory)}[/][grey].[/]"); | |
| } | |
| void CleanupOwnedPidFile(string currentDirectory, string pidFilePath) | |
| { | |
| if (!TryReadDaemonState(pidFilePath, out DaemonState? state)) | |
| { | |
| return; | |
| } | |
| if (state!.Pid != Environment.ProcessId) | |
| { | |
| return; | |
| } | |
| try | |
| { | |
| using Process current = Process.GetCurrentProcess(); | |
| DateTimeOffset actualStartedAtUtc = current.StartTime.ToUniversalTime(); | |
| if (state.StartedAtUtc == default || state.StartedAtUtc == actualStartedAtUtc) | |
| { | |
| DeletePidFileIfPresent(pidFilePath); | |
| AnsiConsole.MarkupLine($"[grey]Removed pid.json for[/] [aqua]{Escape(currentDirectory)}[/][grey].[/]"); | |
| } | |
| } | |
| catch (Win32Exception) | |
| { | |
| } | |
| } | |
| static void DeletePidFileIfPresent(string pidFilePath) | |
| { | |
| if (File.Exists(pidFilePath)) | |
| { | |
| File.Delete(pidFilePath); | |
| } | |
| } | |
| static bool SignalStopEvent(string currentDirectory) | |
| { | |
| if (!OperatingSystem.IsWindows()) | |
| { | |
| return false; | |
| } | |
| try | |
| { | |
| using EventWaitHandle stopEvent = EventWaitHandle.OpenExisting(GetStopEventName(currentDirectory)); | |
| return stopEvent.Set(); | |
| } | |
| catch (WaitHandleCannotBeOpenedException) | |
| { | |
| return false; | |
| } | |
| } | |
| static async Task<bool> WaitForDiagnosticsSocketAsync(string socketPath, TimeSpan timeout, CancellationToken cancellationToken) | |
| { | |
| DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; | |
| while (DateTimeOffset.UtcNow < deadline) | |
| { | |
| cancellationToken.ThrowIfCancellationRequested(); | |
| if (await TryProbeDiagnosticsSocketAsync(socketPath, cancellationToken)) | |
| { | |
| return true; | |
| } | |
| await Task.Delay(250, cancellationToken); | |
| } | |
| return false; | |
| } | |
| static async Task<bool> TryProbeDiagnosticsSocketAsync(string socketPath, CancellationToken cancellationToken) | |
| { | |
| if (!File.Exists(socketPath)) | |
| { | |
| return false; | |
| } | |
| try | |
| { | |
| using Socket socket = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); | |
| using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | |
| timeoutCts.CancelAfter(TimeSpan.FromSeconds(2)); | |
| await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), timeoutCts.Token); | |
| await using NetworkStream stream = new(socket, ownsSocket: false); | |
| using StreamReader reader = new(stream, Encoding.UTF8, leaveOpen: true); | |
| await using StreamWriter writer = new(stream, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; | |
| AttachSocketRequest request = new() { Method = "info" }; | |
| string requestJson = JsonSerializer.Serialize(request, Hex1bAppJsonSerializerContext.Default.AttachSocketRequest); | |
| await writer.WriteLineAsync(requestJson.AsMemory(), timeoutCts.Token); | |
| string? responseLine = await reader.ReadLineAsync(timeoutCts.Token); | |
| if (string.IsNullOrWhiteSpace(responseLine)) | |
| { | |
| return false; | |
| } | |
| AttachSocketResponse? response = JsonSerializer.Deserialize(responseLine, Hex1bAppJsonSerializerContext.Default.AttachSocketResponse); | |
| return response?.Success == true; | |
| } | |
| catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) | |
| { | |
| return false; | |
| } | |
| catch (IOException) | |
| { | |
| return false; | |
| } | |
| catch (SocketException) | |
| { | |
| return false; | |
| } | |
| catch (JsonException) | |
| { | |
| return false; | |
| } | |
| } | |
| static bool TryGetVerifiedProcess(DaemonState state, [NotNullWhen(true)] out Process? process) => | |
| TryGetVerifiedProcessById(state.Pid, state.StartedAtUtc, out process); | |
| static bool TryGetVerifiedProcessById(int pid, DateTimeOffset startedAtUtc, [NotNullWhen(true)] out Process? process) | |
| { | |
| process = null; | |
| try | |
| { | |
| process = Process.GetProcessById(pid); | |
| if (process.HasExited) | |
| { | |
| process.Dispose(); | |
| process = null; | |
| return false; | |
| } | |
| if (startedAtUtc != default && process.StartTime.ToUniversalTime() != startedAtUtc.UtcDateTime) | |
| { | |
| process.Dispose(); | |
| process = null; | |
| return false; | |
| } | |
| return true; | |
| } | |
| catch (ArgumentException) | |
| { | |
| process?.Dispose(); | |
| process = null; | |
| return false; | |
| } | |
| catch (InvalidOperationException) | |
| { | |
| process?.Dispose(); | |
| process = null; | |
| return false; | |
| } | |
| catch (Win32Exception) | |
| { | |
| process?.Dispose(); | |
| process = null; | |
| return false; | |
| } | |
| } | |
| static string GetPidFilePath(string currentDirectory) => Path.Combine(currentDirectory, "pid.json"); | |
| static string GetControllerLogPath(string currentDirectory) => Path.Combine(currentDirectory, ControllerLogFileName); | |
| static string GetStopEventName(string currentDirectory) => $@"Local\Hex1bGracefulProcessShutdown-Stop-{ComputeStableHash(currentDirectory)}"; | |
| static string GetDiagnosticsSocketPath(int pid) | |
| { | |
| string userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); | |
| return Path.Combine(userProfilePath, ".hex1b", "sockets", $"{pid}.diagnostics.socket"); | |
| } | |
| static SelfLaunchSpec GetSelfLaunchSpec() | |
| { | |
| string? processPath = Environment.ProcessPath; | |
| if (!string.IsNullOrWhiteSpace(processPath) && | |
| File.Exists(processPath) && | |
| !IsDotnetHostPath(processPath)) | |
| { | |
| return new SelfLaunchSpec(processPath, []); | |
| } | |
| return new SelfLaunchSpec(DotnetHost, [GetScriptPath(), "--"]); | |
| } | |
| static bool IsDotnetHostPath(string path) | |
| { | |
| string fileName = Path.GetFileName(path); | |
| return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) || | |
| string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase); | |
| } | |
| static SingleInstanceLock? TryAcquireRunLock(string currentDirectory) | |
| { | |
| try | |
| { | |
| string lockDirectoryPath = Path.Combine(Path.GetTempPath(), "Hex1bGracefulProcessShutdown"); | |
| Directory.CreateDirectory(lockDirectoryPath); | |
| string lockPath = Path.Combine(lockDirectoryPath, $"{ComputeStableHash(currentDirectory)}.lock"); | |
| FileStream lockStream = new(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); | |
| return new SingleInstanceLock(lockStream); | |
| } | |
| catch (IOException) | |
| { | |
| return null; | |
| } | |
| } | |
| static string ComputeStableHash(string value) | |
| { | |
| byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToUpperInvariant())); | |
| return Convert.ToHexString(bytes); | |
| } | |
| Hex1bTerminal CreateTargetTerminal(string workloadLogPath, bool enableDiagnostics) | |
| { | |
| Hex1bTerminalBuilder builder = Hex1bTerminal.CreateBuilder() | |
| .WithDimensions(120, 40) | |
| .WithHeadless() | |
| .WithWorkloadLogging(workloadLogPath); | |
| if (enableDiagnostics) | |
| { | |
| builder = builder.WithDiagnostics($"hex1bapp-background-{Environment.ProcessId}", forceEnable: true); | |
| } | |
| return builder | |
| .WithPtyProcess(options => | |
| { | |
| options.FileName = TargetCommand; | |
| options.Arguments = new List<string>(TargetArgumentList); | |
| options.WorkingDirectory = TargetWorkingDirectory; | |
| options.WindowsPtyMode = WindowsPtyMode.RequireProxy; | |
| options.InheritEnvironment = true; | |
| }) | |
| .Build(); | |
| } | |
| static string CreateWorkloadLogPath(int sessionToken) | |
| { | |
| string logDirectoryPath = Path.Combine(Path.GetTempPath(), "Hex1bGracefulProcessShutdown", "logs"); | |
| Directory.CreateDirectory(logDirectoryPath); | |
| return Path.Combine(logDirectoryPath, $"copilot-session-{sessionToken}-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}.log"); | |
| } | |
| static string CreateSessionDiagnosticLogPath(int sessionToken) | |
| { | |
| string logDirectoryPath = Path.Combine(Path.GetTempPath(), "Hex1bGracefulProcessShutdown", "logs"); | |
| Directory.CreateDirectory(logDirectoryPath); | |
| return Path.Combine(logDirectoryPath, $"copilot-session-{sessionToken}-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}.controller.log"); | |
| } | |
| static string DescribeOutcome(TrackedSessionOutcome outcome) | |
| { | |
| if (outcome.Failure is not null) | |
| { | |
| return $"faulted: {outcome.Failure.Message}"; | |
| } | |
| if (outcome.Cancelled) | |
| { | |
| return "cancelled"; | |
| } | |
| return $"exit code {outcome.ExitCode ?? 0}"; | |
| } | |
| static string BuildStartupFailureMessage(string prefix, TrackedSession session, TrackedSessionOutcome outcome) | |
| { | |
| string details = DescribeOutcome(outcome); | |
| string workloadLogTail = ReadWorkloadLogTail(session.WorkloadLogPath); | |
| string sessionDiagnosticLogTail = ReadWorkloadLogTail(session.SessionDiagnosticLogPath); | |
| if (!string.IsNullOrWhiteSpace(sessionDiagnosticLogTail)) | |
| { | |
| details = $"{details}{Environment.NewLine}Controller session log:{Environment.NewLine}{sessionDiagnosticLogTail}"; | |
| } | |
| if (string.IsNullOrWhiteSpace(workloadLogTail)) | |
| { | |
| return $"{prefix} Outcome: {details}"; | |
| } | |
| return $"{prefix} Outcome: {details}{Environment.NewLine}Workload log:{Environment.NewLine}{workloadLogTail}"; | |
| } | |
| static void AppendControllerLog(string controllerLogPath, string message) | |
| { | |
| string line = $"[{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}"; | |
| TryWriteLogFile(controllerLogPath, line, append: true); | |
| } | |
| static void AppendSessionDiagnostic(string sessionDiagnosticLogPath, string message) | |
| { | |
| string line = $"[{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}"; | |
| TryWriteLogFile(sessionDiagnosticLogPath, line, append: true); | |
| } | |
| static bool TryWriteLogFile(string path, string content, bool append) | |
| { | |
| string? directoryPath = Path.GetDirectoryName(path); | |
| if (!string.IsNullOrWhiteSpace(directoryPath)) | |
| { | |
| Directory.CreateDirectory(directoryPath); | |
| } | |
| for (int attempt = 0; attempt < 5; attempt++) | |
| { | |
| try | |
| { | |
| FileMode fileMode = append ? FileMode.Append : FileMode.Create; | |
| using FileStream stream = new(path, fileMode, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); | |
| using StreamWriter writer = new(stream, Encoding.UTF8); | |
| writer.Write(content); | |
| writer.Flush(); | |
| return true; | |
| } | |
| catch (IOException) | |
| { | |
| if (attempt == 4) | |
| { | |
| return false; | |
| } | |
| Thread.Sleep(25); | |
| } | |
| catch (UnauthorizedAccessException) | |
| { | |
| if (attempt == 4) | |
| { | |
| return false; | |
| } | |
| Thread.Sleep(25); | |
| } | |
| } | |
| return false; | |
| } | |
| static string ReadWorkloadLogTail(string workloadLogPath) | |
| { | |
| try | |
| { | |
| if (!File.Exists(workloadLogPath)) | |
| { | |
| return string.Empty; | |
| } | |
| string logContent = File.ReadAllText(workloadLogPath).Trim(); | |
| if (string.IsNullOrWhiteSpace(logContent)) | |
| { | |
| return string.Empty; | |
| } | |
| if (logContent.Length <= WorkloadLogTailCharacterLimit) | |
| { | |
| return logContent; | |
| } | |
| return $"...{logContent[^WorkloadLogTailCharacterLimit..]}"; | |
| } | |
| catch (IOException) | |
| { | |
| return string.Empty; | |
| } | |
| catch (UnauthorizedAccessException) | |
| { | |
| return string.Empty; | |
| } | |
| } | |
| static string Escape(string value) => Markup.Escape(value); | |
| static string DescribeKey(ConsoleKeyInfo key) => | |
| key.KeyChar == '\0' ? key.Key.ToString() : key.KeyChar.ToString(); | |
| static string GetScriptPath([CallerFilePath] string path = "") => path; | |
| sealed record DaemonState | |
| { | |
| public required int Pid { get; init; } | |
| public required DateTimeOffset StartedAtUtc { get; init; } | |
| } | |
| sealed record AttachSocketRequest | |
| { | |
| [JsonPropertyName("method")] | |
| public required string Method { get; init; } | |
| } | |
| sealed record AttachSocketResponse | |
| { | |
| [JsonPropertyName("success")] | |
| public bool Success { get; init; } | |
| [JsonPropertyName("width")] | |
| public int? Width { get; init; } | |
| [JsonPropertyName("height")] | |
| public int? Height { get; init; } | |
| [JsonPropertyName("leader")] | |
| public bool? Leader { get; init; } | |
| [JsonPropertyName("data")] | |
| public string? Data { get; init; } | |
| [JsonPropertyName("error")] | |
| public string? Error { get; init; } | |
| } | |
| sealed class TrackedSession( | |
| int sessionToken, | |
| Hex1bTerminal terminal, | |
| CancellationTokenSource runCts, | |
| Task<TrackedSessionOutcome> completion, | |
| string workloadLogPath, | |
| string sessionDiagnosticLogPath, | |
| DateTimeOffset startedAtUtc) | |
| { | |
| public int SessionToken { get; } = sessionToken; | |
| public Hex1bTerminal Terminal { get; } = terminal; | |
| public CancellationTokenSource RunCts { get; } = runCts; | |
| public Task<TrackedSessionOutcome> Completion { get; } = completion; | |
| public string WorkloadLogPath { get; } = workloadLogPath; | |
| public string SessionDiagnosticLogPath { get; } = sessionDiagnosticLogPath; | |
| public DateTimeOffset StartedAtUtc { get; } = startedAtUtc; | |
| } | |
| sealed record TrackedSessionOutcome(int? ExitCode, bool Cancelled, Exception? Failure, DateTimeOffset EndedAtUtc); | |
| sealed record SelfLaunchSpec(string FileName, IReadOnlyList<string> ArgumentPrefix); | |
| [JsonSerializable(typeof(DaemonState))] | |
| [JsonSerializable(typeof(AttachSocketRequest))] | |
| [JsonSerializable(typeof(AttachSocketResponse))] | |
| sealed partial class Hex1bAppJsonSerializerContext : JsonSerializerContext; | |
| enum StopProcessOutcome | |
| { | |
| Graceful, | |
| Killed, | |
| AlreadyExited, | |
| } | |
| sealed class SingleInstanceLock(FileStream lockStream) : IDisposable | |
| { | |
| private readonly FileStream _lockStream = lockStream; | |
| private bool _disposed; | |
| public void Dispose() | |
| { | |
| if (_disposed) | |
| { | |
| return; | |
| } | |
| _lockStream.Dispose(); | |
| _disposed = true; | |
| } | |
| } | |
| enum AttachTransportFrameKind | |
| { | |
| Output, | |
| Resize, | |
| LeaderChanged, | |
| Exit, | |
| } | |
| readonly record struct AttachTransportFrame(AttachTransportFrameKind Kind, ReadOnlyMemory<byte> Data) | |
| { | |
| public static AttachTransportFrame Output(ReadOnlyMemory<byte> data) => new(AttachTransportFrameKind.Output, data); | |
| public static AttachTransportFrame Resize(int width, int height) | |
| { | |
| byte[] bytes = new byte[8]; | |
| BitConverter.TryWriteBytes(bytes.AsSpan(0, 4), width); | |
| BitConverter.TryWriteBytes(bytes.AsSpan(4, 4), height); | |
| return new AttachTransportFrame(AttachTransportFrameKind.Resize, bytes); | |
| } | |
| public static AttachTransportFrame LeaderChanged(bool isLeader) => | |
| new(AttachTransportFrameKind.LeaderChanged, new byte[] { isLeader ? (byte)1 : (byte)0 }); | |
| public static AttachTransportFrame Exit() => new(AttachTransportFrameKind.Exit, default); | |
| public (int Width, int Height) GetResize() | |
| { | |
| ReadOnlySpan<byte> span = Data.Span; | |
| return (BitConverter.ToInt32(span[..4]), BitConverter.ToInt32(span[4..8])); | |
| } | |
| public bool GetIsLeader() => !Data.IsEmpty && Data.Span[0] != 0; | |
| } | |
| sealed record AttachTransportResult( | |
| bool Success, | |
| int Width, | |
| int Height, | |
| bool IsLeader, | |
| string? InitialScreen, | |
| string? Error); | |
| sealed class SocketAttachTransport(string socketPath) : IAsyncDisposable | |
| { | |
| private readonly string _socketPath = socketPath; | |
| private Socket? _socket; | |
| private NetworkStream? _stream; | |
| private StreamReader? _reader; | |
| private StreamWriter? _writer; | |
| public async Task<AttachTransportResult> ConnectAsync(CancellationToken cancellationToken) | |
| { | |
| try | |
| { | |
| _socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); | |
| await _socket.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), cancellationToken); | |
| _stream = new NetworkStream(_socket, ownsSocket: false); | |
| _reader = new StreamReader(_stream, Encoding.UTF8, leaveOpen: true); | |
| _writer = new StreamWriter(_stream, Encoding.UTF8, bufferSize: 1024, leaveOpen: true) { AutoFlush = true }; | |
| AttachSocketRequest request = new() { Method = "attach" }; | |
| string requestJson = JsonSerializer.Serialize(request, Hex1bAppJsonSerializerContext.Default.AttachSocketRequest); | |
| await _writer.WriteLineAsync(requestJson.AsMemory(), cancellationToken); | |
| string? responseLine = await _reader.ReadLineAsync(cancellationToken); | |
| if (string.IsNullOrWhiteSpace(responseLine)) | |
| { | |
| return new AttachTransportResult(false, 0, 0, false, null, "Empty response from terminal host."); | |
| } | |
| AttachSocketResponse? response = JsonSerializer.Deserialize(responseLine, Hex1bAppJsonSerializerContext.Default.AttachSocketResponse); | |
| if (response is null) | |
| { | |
| return new AttachTransportResult(false, 0, 0, false, null, "Failed to deserialize attach response."); | |
| } | |
| if (!response.Success) | |
| { | |
| return new AttachTransportResult(false, 0, 0, false, null, response.Error ?? "Attach failed."); | |
| } | |
| return new AttachTransportResult(true, response.Width ?? 0, response.Height ?? 0, response.Leader ?? false, response.Data, null); | |
| } | |
| catch (Exception ex) when (ex is IOException or SocketException or JsonException) | |
| { | |
| return new AttachTransportResult(false, 0, 0, false, null, ex.Message); | |
| } | |
| } | |
| public Task SendInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) => | |
| WriteLineAsync($"i:{Convert.ToBase64String(data.Span)}", cancellationToken); | |
| public Task SendResizeAsync(int width, int height, CancellationToken cancellationToken) => | |
| WriteLineAsync($"r:{width},{height}", cancellationToken); | |
| public Task ClaimLeadAsync(CancellationToken cancellationToken) => | |
| WriteLineAsync("lead", cancellationToken); | |
| public Task DetachAsync(CancellationToken cancellationToken) => | |
| WriteLineAsync("detach", cancellationToken); | |
| public Task ShutdownAsync(CancellationToken cancellationToken) => | |
| WriteLineAsync("shutdown", cancellationToken); | |
| public async IAsyncEnumerable<AttachTransportFrame> ReadFramesAsync([EnumeratorCancellation] CancellationToken cancellationToken) | |
| { | |
| if (_reader is null) | |
| { | |
| yield break; | |
| } | |
| while (!cancellationToken.IsCancellationRequested) | |
| { | |
| string? line; | |
| try | |
| { | |
| line = await _reader.ReadLineAsync(cancellationToken); | |
| } | |
| catch (OperationCanceledException) | |
| { | |
| yield break; | |
| } | |
| catch (IOException) | |
| { | |
| yield break; | |
| } | |
| if (line is null) | |
| { | |
| yield break; | |
| } | |
| if (line.StartsWith("o:", StringComparison.Ordinal)) | |
| { | |
| byte[] outputBytes = Convert.FromBase64String(line[2..]); | |
| yield return AttachTransportFrame.Output(outputBytes); | |
| continue; | |
| } | |
| if (line.StartsWith("r:", StringComparison.Ordinal)) | |
| { | |
| string[] parts = line[2..].Split(','); | |
| if (parts.Length == 2 && | |
| int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int width) && | |
| int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int height)) | |
| { | |
| yield return AttachTransportFrame.Resize(width, height); | |
| } | |
| continue; | |
| } | |
| if (line.StartsWith("leader:", StringComparison.Ordinal)) | |
| { | |
| yield return AttachTransportFrame.LeaderChanged(string.Equals(line[7..], "true", StringComparison.OrdinalIgnoreCase)); | |
| continue; | |
| } | |
| if (string.Equals(line, "exit", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| yield return AttachTransportFrame.Exit(); | |
| yield break; | |
| } | |
| } | |
| } | |
| public async ValueTask DisposeAsync() | |
| { | |
| if (_writer is not null) | |
| { | |
| await _writer.DisposeAsync(); | |
| _writer = null; | |
| } | |
| _reader?.Dispose(); | |
| _reader = null; | |
| if (_stream is not null) | |
| { | |
| await _stream.DisposeAsync(); | |
| _stream = null; | |
| } | |
| _socket?.Dispose(); | |
| _socket = null; | |
| } | |
| private Task WriteLineAsync(string line, CancellationToken cancellationToken) | |
| { | |
| if (_writer is null) | |
| { | |
| return Task.CompletedTask; | |
| } | |
| return _writer.WriteLineAsync(line.AsMemory(), cancellationToken); | |
| } | |
| } | |
| sealed class JoinSessionClient(SocketAttachTransport transport) : IAsyncDisposable | |
| { | |
| private readonly SocketAttachTransport _transport = transport; | |
| private readonly Pipe _outputPipe = new(); | |
| private bool _isLeader; | |
| private bool _shutdownRequested; | |
| private int _remoteWidth; | |
| private int _remoteHeight; | |
| private int _displayWidth; | |
| private int _displayHeight; | |
| private Hex1bApp? _app; | |
| private Hex1bTerminal? _embeddedTerminal; | |
| private TerminalWidgetHandle? _handle; | |
| private CancellationTokenSource? _appCts; | |
| public async Task<int> RunAsync(CancellationToken cancellationToken) | |
| { | |
| AttachTransportResult connectResult = await _transport.ConnectAsync(cancellationToken); | |
| if (!connectResult.Success) | |
| { | |
| AnsiConsole.MarkupLine($"[red]Failed to join background instance:[/] {Markup.Escape(connectResult.Error ?? "Unknown error.")}"); | |
| return 1; | |
| } | |
| _isLeader = connectResult.IsLeader; | |
| _remoteWidth = connectResult.Width; | |
| _remoteHeight = connectResult.Height; | |
| bool preSizedToDisplay = await TryApplyInitialResizeAsync(cancellationToken); | |
| InputInterceptStream inputIntercept = new(this); | |
| StreamWorkloadAdapter workload = new(_outputPipe.Reader.AsStream(), inputIntercept) | |
| { | |
| Width = _remoteWidth, | |
| Height = _remoteHeight, | |
| }; | |
| _embeddedTerminal = Hex1bTerminal.CreateBuilder() | |
| .WithDimensions(_remoteWidth, _remoteHeight) | |
| .WithWorkload(workload) | |
| .WithTerminalWidget(out TerminalWidgetHandle handle) | |
| .Build(); | |
| _handle = handle; | |
| _appCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | |
| Task<int> embeddedRunTask = _embeddedTerminal.RunAsync(_appCts.Token); | |
| Task networkOutputTask = PumpNetworkOutputAsync(_appCts.Token); | |
| if (!preSizedToDisplay && !string.IsNullOrEmpty(connectResult.InitialScreen)) | |
| { | |
| byte[] initialBytes = Encoding.UTF8.GetBytes(connectResult.InitialScreen); | |
| await _outputPipe.Writer.WriteAsync(initialBytes, cancellationToken); | |
| await _outputPipe.Writer.FlushAsync(cancellationToken); | |
| } | |
| await using Hex1bTerminal displayTerminal = Hex1bTerminal.CreateBuilder() | |
| .AddPresentationFilter(new ResizeFilter(this)) | |
| .WithHex1bApp((app, _) => | |
| { | |
| _app = app; | |
| return ctx => ctx.Terminal(handle).Fill(); | |
| }) | |
| .Build(); | |
| try | |
| { | |
| await displayTerminal.RunAsync(_appCts.Token); | |
| } | |
| catch (OperationCanceledException) | |
| { | |
| } | |
| finally | |
| { | |
| if (_appCts is not null) | |
| { | |
| await _appCts.CancelAsync(); | |
| } | |
| if (_shutdownRequested) | |
| { | |
| try | |
| { | |
| await _transport.ShutdownAsync(CancellationToken.None); | |
| } | |
| catch | |
| { | |
| } | |
| } | |
| else | |
| { | |
| try | |
| { | |
| await _transport.DetachAsync(CancellationToken.None); | |
| } | |
| catch | |
| { | |
| } | |
| } | |
| _outputPipe.Writer.Complete(); | |
| try | |
| { | |
| await networkOutputTask; | |
| } | |
| catch | |
| { | |
| } | |
| try | |
| { | |
| await embeddedRunTask; | |
| } | |
| catch | |
| { | |
| } | |
| } | |
| return 0; | |
| } | |
| public async ValueTask DisposeAsync() | |
| { | |
| if (_appCts is not null) | |
| { | |
| await _appCts.CancelAsync(); | |
| _appCts.Dispose(); | |
| _appCts = null; | |
| } | |
| if (_embeddedTerminal is not null) | |
| { | |
| await _embeddedTerminal.DisposeAsync(); | |
| _embeddedTerminal = null; | |
| } | |
| } | |
| internal async Task SendInputAsync(byte[] buffer, int offset, int count) | |
| { | |
| await _transport.SendInputAsync(buffer.AsMemory(offset, count), CancellationToken.None); | |
| } | |
| internal async Task HandleCommandByteAsync(byte commandByte) | |
| { | |
| switch (commandByte) | |
| { | |
| case (byte)'d': | |
| case (byte)'D': | |
| _app?.RequestStop(); | |
| break; | |
| case (byte)'l': | |
| case (byte)'L': | |
| await _transport.ClaimLeadAsync(CancellationToken.None); | |
| break; | |
| case (byte)'q': | |
| case (byte)'Q': | |
| _shutdownRequested = true; | |
| _app?.RequestStop(); | |
| break; | |
| case 0x1D: | |
| await _transport.SendInputAsync(new byte[] { 0x1D }, CancellationToken.None); | |
| break; | |
| } | |
| } | |
| private async Task PumpNetworkOutputAsync(CancellationToken cancellationToken) | |
| { | |
| try | |
| { | |
| await foreach (AttachTransportFrame frame in _transport.ReadFramesAsync(cancellationToken)) | |
| { | |
| switch (frame.Kind) | |
| { | |
| case AttachTransportFrameKind.Output: | |
| await _outputPipe.Writer.WriteAsync(frame.Data, cancellationToken); | |
| await _outputPipe.Writer.FlushAsync(cancellationToken); | |
| _app?.Invalidate(); | |
| break; | |
| case AttachTransportFrameKind.Resize: | |
| (int width, int height) = frame.GetResize(); | |
| _remoteWidth = width; | |
| _remoteHeight = height; | |
| _handle?.Resize(width, height); | |
| _embeddedTerminal?.Resize(width, height); | |
| _app?.Invalidate(); | |
| break; | |
| case AttachTransportFrameKind.LeaderChanged: | |
| _isLeader = frame.GetIsLeader(); | |
| if (_isLeader) | |
| { | |
| await SendResizeForCurrentDisplayAsync(); | |
| } | |
| _app?.Invalidate(); | |
| break; | |
| case AttachTransportFrameKind.Exit: | |
| _app?.RequestStop(); | |
| return; | |
| } | |
| } | |
| } | |
| catch (OperationCanceledException) | |
| { | |
| } | |
| catch | |
| { | |
| _app?.RequestStop(); | |
| } | |
| finally | |
| { | |
| _outputPipe.Writer.Complete(); | |
| } | |
| } | |
| private async Task HandleDisplayResizeAsync(int displayWidth, int displayHeight) | |
| { | |
| _displayWidth = displayWidth; | |
| _displayHeight = displayHeight; | |
| if (_isLeader) | |
| { | |
| await SendResizeForCurrentDisplayAsync(); | |
| } | |
| } | |
| private async Task<bool> TryApplyInitialResizeAsync(CancellationToken cancellationToken) | |
| { | |
| if (!_isLeader) | |
| { | |
| return false; | |
| } | |
| (int Width, int Height)? displaySize = TryGetCurrentDisplaySize(); | |
| if (displaySize is null) | |
| { | |
| return false; | |
| } | |
| _displayWidth = displaySize.Value.Width; | |
| _displayHeight = displaySize.Value.Height; | |
| if (_displayWidth <= 0 || _displayHeight <= 0) | |
| { | |
| return false; | |
| } | |
| if (_displayWidth == _remoteWidth && _displayHeight == _remoteHeight) | |
| { | |
| return false; | |
| } | |
| _remoteWidth = _displayWidth; | |
| _remoteHeight = _displayHeight; | |
| await _transport.SendResizeAsync(_remoteWidth, _remoteHeight, cancellationToken); | |
| return true; | |
| } | |
| private async Task SendResizeForCurrentDisplayAsync() | |
| { | |
| if (_displayWidth <= 0 || _displayHeight <= 0) | |
| { | |
| return; | |
| } | |
| if (_displayWidth == _remoteWidth && _displayHeight == _remoteHeight) | |
| { | |
| return; | |
| } | |
| _remoteWidth = _displayWidth; | |
| _remoteHeight = _displayHeight; | |
| _handle?.Resize(_remoteWidth, _remoteHeight); | |
| _embeddedTerminal?.Resize(_remoteWidth, _remoteHeight); | |
| await _transport.SendResizeAsync(_remoteWidth, _remoteHeight, CancellationToken.None); | |
| _app?.Invalidate(); | |
| } | |
| private static (int Width, int Height)? TryGetCurrentDisplaySize() | |
| { | |
| try | |
| { | |
| int width = Console.WindowWidth; | |
| int height = Console.WindowHeight; | |
| return width > 0 && height > 0 ? (width, height) : null; | |
| } | |
| catch (IOException) | |
| { | |
| return null; | |
| } | |
| catch (InvalidOperationException) | |
| { | |
| return null; | |
| } | |
| } | |
| private sealed class ResizeFilter(JoinSessionClient client) : IHex1bTerminalPresentationFilter | |
| { | |
| public ValueTask OnSessionStartAsync(int width, int height, DateTimeOffset timestamp, CancellationToken ct = default) => | |
| new(client.HandleDisplayResizeAsync(width, height)); | |
| public ValueTask<IReadOnlyList<AnsiToken>> OnOutputAsync(IReadOnlyList<AppliedToken> appliedTokens, TimeSpan elapsed, CancellationToken ct = default) => | |
| ValueTask.FromResult<IReadOnlyList<AnsiToken>>([.. appliedTokens.Select(token => token.Token)]); | |
| public ValueTask OnInputAsync(IReadOnlyList<AnsiToken> tokens, TimeSpan elapsed, CancellationToken ct = default) => default; | |
| public ValueTask OnResizeAsync(int width, int height, TimeSpan elapsed, CancellationToken ct = default) => | |
| new(client.HandleDisplayResizeAsync(width, height)); | |
| public ValueTask OnSessionEndAsync(TimeSpan elapsed, CancellationToken ct = default) => default; | |
| } | |
| private sealed class InputInterceptStream(JoinSessionClient client) : Stream | |
| { | |
| private bool _inCommandMode; | |
| public override bool CanRead => false; | |
| public override bool CanSeek => false; | |
| public override bool CanWrite => true; | |
| public override long Length => throw new NotSupportedException(); | |
| public override long Position | |
| { | |
| get => throw new NotSupportedException(); | |
| set => throw new NotSupportedException(); | |
| } | |
| public override void Flush() | |
| { | |
| } | |
| public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; | |
| public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); | |
| public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); | |
| public override void SetLength(long value) => throw new NotSupportedException(); | |
| public override void Write(byte[] buffer, int offset, int count) => | |
| WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); | |
| public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) | |
| { | |
| const byte CtrlRightBracket = 0x1D; | |
| int start = offset; | |
| for (int index = offset; index < offset + count; index++) | |
| { | |
| if (_inCommandMode) | |
| { | |
| _inCommandMode = false; | |
| await client.HandleCommandByteAsync(buffer[index]); | |
| start = index + 1; | |
| continue; | |
| } | |
| if (buffer[index] == CtrlRightBracket) | |
| { | |
| if (index > start) | |
| { | |
| await client.SendInputAsync(buffer, start, index - start); | |
| } | |
| _inCommandMode = true; | |
| start = index + 1; | |
| } | |
| } | |
| if (start < offset + count && !_inCommandMode) | |
| { | |
| await client.SendInputAsync(buffer, start, offset + count - start); | |
| } | |
| } | |
| public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) | |
| { | |
| byte[] copy = buffer.ToArray(); | |
| await WriteAsync(copy, 0, copy.Length, cancellationToken); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment