Initial commit

This commit is contained in:
Holger Börchers
2026-01-30 15:31:43 +01:00
commit 894fbbfa5a
22 changed files with 1701 additions and 0 deletions

9
ParentWpf/App.xaml Normal file
View File

@@ -0,0 +1,9 @@
<Application x:Class="ParentWpf.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ParentWpf"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

13
ParentWpf/App.xaml.cs Normal file
View File

@@ -0,0 +1,13 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace ParentWpf;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}

10
ParentWpf/AssemblyInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

129
ParentWpf/ChildSession.cs Normal file
View File

@@ -0,0 +1,129 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text.Json;
using CommIpc;
namespace ParentWpf;
public sealed class ChildSession : NotifyBase, IAsyncDisposable
{
private readonly SemaphoreSlim _writeLock = new(1, 1);
private string _status = "Starting";
private double _progressPercent;
private string? _currentWorkId;
private bool _isConnected;
public int Id { get; }
public string PipeName { get; }
public string PipePath => CommIpc.PipeName.Describe(PipeName);
public NamedPipeServerStream Pipe { get; }
public Process Process { get; }
public CancellationTokenSource LifetimeCts { get; } = new();
public ObservableCollection<string> Logs { get; } = new();
public bool IsConnected
{
get => _isConnected;
set
{
if (SetField(ref _isConnected, value))
{
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(StatusLine));
}
}
}
public string Status
{
get => _status;
set
{
if (SetField(ref _status, value))
{
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(StatusLine));
}
}
}
public string DisplayName => $"Child {Id} ({(IsConnected ? "Connected" : "Disconnected")})";
public string StatusLine => $"{Status} | Work: {(_currentWorkId ?? "-")}";
public double ProgressPercent
{
get => _progressPercent;
set => SetField(ref _progressPercent, value);
}
public string? CurrentWorkId
{
get => _currentWorkId;
set
{
if (SetField(ref _currentWorkId, value))
{
OnPropertyChanged(nameof(StatusLine));
}
}
}
public ChildSession(int id, string pipeName, NamedPipeServerStream pipe, Process process)
{
Id = id;
PipeName = pipeName;
Pipe = pipe;
Process = process;
}
public async Task SendAsync(IpcFrame frame, CancellationToken cancellationToken)
{
await _writeLock.WaitAsync(cancellationToken);
try
{
await IpcProtocol.WriteFrameAsync(Pipe, frame, cancellationToken);
}
finally
{
_writeLock.Release();
}
}
public void AddLog(string line)
{
// keep it from growing without bounds during prototyping
if (Logs.Count > 2000)
{
Logs.RemoveAt(0);
}
Logs.Add(line);
}
public async ValueTask DisposeAsync()
{
try
{
LifetimeCts.Cancel();
}
catch { }
try
{
if (!Process.HasExited)
{
Process.Kill(entireProcessTree: true);
}
}
catch { }
try { Pipe.Dispose(); } catch { }
try { Process.Dispose(); } catch { }
try { LifetimeCts.Dispose(); } catch { }
try { _writeLock.Dispose(); } catch { }
await Task.CompletedTask;
}
}

297
ParentWpf/MainViewModel.cs Normal file
View File

@@ -0,0 +1,297 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Text.Json;
using System.Windows;
using CommIpc;
namespace ParentWpf;
public sealed class MainViewModel : NotifyBase, IAsyncDisposable
{
private ChildSession? _selectedChild;
private bool _childrenStarted;
private readonly CancellationTokenSource _shutdownCts = new();
public ObservableCollection<ChildSession> Children { get; } = new();
public ChildSession? SelectedChild
{
get => _selectedChild;
set => SetField(ref _selectedChild, value);
}
public async Task StartChildrenAsync(int count)
{
if (_childrenStarted)
{
return;
}
_childrenStarted = true;
for (int i = 1; i <= count; i++)
{
await StartChildAsync(i, _shutdownCts.Token);
}
}
public async Task PingSelectedAsync()
{
if (SelectedChild is null)
{
return;
}
string corr = Guid.NewGuid().ToString("N");
await SelectedChild.SendAsync(
new IpcFrame(IpcKinds.Ping, CorrelationId: corr, Payload: IpcProtocol.ToJsonElement(new { from = "parent" })),
SelectedChild.LifetimeCts.Token);
SelectedChild.AddLog($"[parent] -> ping ({corr})");
}
public async Task StartWorkSelectedAsync(int steps = 30, int delayMs = 120)
{
if (SelectedChild is null)
{
return;
}
string corr = Guid.NewGuid().ToString("N");
SelectedChild.CurrentWorkId = corr;
SelectedChild.ProgressPercent = 0;
await SelectedChild.SendAsync(
new IpcFrame(
IpcKinds.StartWork,
CorrelationId: corr,
Payload: IpcProtocol.ToJsonElement(new { steps, delayMs })),
SelectedChild.LifetimeCts.Token);
SelectedChild.AddLog($"[parent] -> startWork (corr={corr}, steps={steps}, delayMs={delayMs})");
}
public async Task CancelWorkSelectedAsync()
{
if (SelectedChild is null)
{
return;
}
string corr = SelectedChild.CurrentWorkId ?? Guid.NewGuid().ToString("N");
await SelectedChild.SendAsync(
new IpcFrame(IpcKinds.CancelWork, CorrelationId: corr),
SelectedChild.LifetimeCts.Token);
SelectedChild.AddLog($"[parent] -> cancelWork (corr={corr})");
}
private async Task StartChildAsync(int childId, CancellationToken cancellationToken)
{
string pipeName = PipeName.ForChild(childId, Environment.ProcessId);
var server = new NamedPipeServerStream(
pipeName: pipeName,
direction: PipeDirection.InOut,
maxNumberOfServerInstances: 1,
transmissionMode: PipeTransmissionMode.Byte,
options: PipeOptions.Asynchronous);
Task waitForConnection = server.WaitForConnectionAsync(cancellationToken);
Process process = StartChildProcess(childId, pipeName);
var session = new ChildSession(childId, pipeName, server, process);
Children.Add(session);
SelectedChild ??= session;
session.AddLog($"[parent] Starting {process.StartInfo.FileName} {process.StartInfo.Arguments}");
await waitForConnection;
session.IsConnected = true;
session.Status = "Connected";
session.AddLog($"[parent] Connected to {PipeName.Describe(pipeName)}");
_ = Task.Run(() => ReceiveLoopAsync(session), _shutdownCts.Token);
}
private static Process StartChildProcess(int childId, string pipeName)
{
// Prefer spawning the built exe. If not found, fall back to running the dll via dotnet.
string? solutionRoot = TryFindSolutionRoot(AppContext.BaseDirectory);
if (solutionRoot is null)
{
throw new InvalidOperationException("Could not find solution root (CommTester.slnx).");
}
string debugExe = Path.Combine(solutionRoot, "ChildWorker", "bin", "Debug", "net10.0", "ChildWorker.exe");
string releaseExe = Path.Combine(solutionRoot, "ChildWorker", "bin", "Release", "net10.0", "ChildWorker.exe");
string debugDll = Path.Combine(solutionRoot, "ChildWorker", "bin", "Debug", "net10.0", "ChildWorker.dll");
string releaseDll = Path.Combine(solutionRoot, "ChildWorker", "bin", "Release", "net10.0", "ChildWorker.dll");
string fileName;
string arguments;
if (File.Exists(debugExe))
{
fileName = debugExe;
arguments = $"--pipe \"{pipeName}\" --id {childId}";
}
else if (File.Exists(releaseExe))
{
fileName = releaseExe;
arguments = $"--pipe \"{pipeName}\" --id {childId}";
}
else if (File.Exists(debugDll))
{
fileName = "dotnet";
arguments = $"\"{debugDll}\" --pipe \"{pipeName}\" --id {childId}";
}
else if (File.Exists(releaseDll))
{
fileName = "dotnet";
arguments = $"\"{releaseDll}\" --pipe \"{pipeName}\" --id {childId}";
}
else
{
// Last-resort: run via project (slower but robust in fresh checkouts).
string csproj = Path.Combine(solutionRoot, "ChildWorker", "ChildWorker.csproj");
fileName = "dotnet";
arguments = $"run --project \"{csproj}\" -- --pipe \"{pipeName}\" --id {childId}";
}
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true
};
var p = new Process { StartInfo = psi, EnableRaisingEvents = true };
p.Start();
return p;
}
private static string? TryFindSolutionRoot(string baseDirectory)
{
var dir = new DirectoryInfo(baseDirectory);
for (int i = 0; i < 10 && dir is not null; i++)
{
string slnx = Path.Combine(dir.FullName, "CommTester.slnx");
if (File.Exists(slnx))
{
return dir.FullName;
}
dir = dir.Parent;
}
return null;
}
private async Task ReceiveLoopAsync(ChildSession session)
{
try
{
while (!session.LifetimeCts.IsCancellationRequested)
{
IpcFrame? frame = await IpcProtocol.ReadFrameAsync(session.Pipe, cancellationToken: session.LifetimeCts.Token);
if (frame is null)
{
break;
}
await Application.Current.Dispatcher.InvokeAsync(() =>
{
HandleIncomingFrame(session, frame);
});
}
}
catch (OperationCanceledException)
{
// ignore
}
catch (Exception ex)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
session.Status = "Error";
session.AddLog($"[parent] Receive loop error: {ex.Message}");
});
}
finally
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
session.IsConnected = false;
session.Status = "Disconnected";
session.AddLog("[parent] Disconnected.");
});
}
}
private static void HandleIncomingFrame(ChildSession session, IpcFrame frame)
{
switch (frame.Kind)
{
case IpcKinds.Hello:
{
var hello = IpcProtocol.FromJsonElement<HelloPayload>(frame.Payload);
session.AddLog($"[child] hello: id={hello?.ChildId}, pid={hello?.Pid}");
break;
}
case IpcKinds.Pong:
session.AddLog($"[child] pong ({frame.CorrelationId})");
break;
case IpcKinds.Log:
{
var log = IpcProtocol.FromJsonElement<LogPayload>(frame.Payload);
session.AddLog($"[child:{log?.Level ?? "info"}] {log?.Message}");
break;
}
case IpcKinds.Progress:
{
var prog = IpcProtocol.FromJsonElement<ProgressPayload>(frame.Payload);
if (prog is not null)
{
session.ProgressPercent = prog.Percent;
session.Status = $"Working ({prog.Step}/{prog.Total})";
}
break;
}
case IpcKinds.Result:
{
var res = IpcProtocol.FromJsonElement<ResultPayload>(frame.Payload);
session.ProgressPercent = 100;
session.Status = "Idle";
session.AddLog($"[child] result: {res?.Message}");
break;
}
case IpcKinds.Error:
{
var err = IpcProtocol.FromJsonElement<ErrorPayload>(frame.Payload);
session.Status = "Error";
session.AddLog($"[child] error: {err?.Message}");
break;
}
default:
session.AddLog($"[child] {frame.Kind} ({frame.CorrelationId})");
break;
}
}
public async ValueTask DisposeAsync()
{
_shutdownCts.Cancel();
foreach (var child in Children.ToArray())
{
try { await child.DisposeAsync(); } catch { }
}
_shutdownCts.Dispose();
}
private sealed record HelloPayload(int ChildId, int Pid);
private sealed record LogPayload(string Level, string Message);
private sealed record ProgressPayload(int Step, int Total, double Percent);
private sealed record ResultPayload(string Message);
private sealed record ErrorPayload(string Message);
}

92
ParentWpf/MainWindow.xaml Normal file
View File

@@ -0,0 +1,92 @@
<Window x:Class="ParentWpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:ParentWpf"
mc:Ignorable="d"
Title="CommTester (Parent)"
Height="600"
Width="1000">
<Grid Margin="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="12"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0"
Grid.ColumnSpan="3"
Orientation="Horizontal">
<Button Content="Start 3 Children"
Padding="12,6"
Margin="0,0,8,0"
Click="StartChildren_Click"/>
<Button Content="Ping Selected"
Padding="12,6"
Margin="0,0,8,0"
Click="PingSelected_Click"/>
<Button Content="Start Work"
Padding="12,6"
Margin="0,0,8,0"
Click="StartWork_Click"/>
<Button Content="Cancel Work"
Padding="12,6"
Click="CancelWork_Click"/>
</StackPanel>
<GroupBox Grid.Row="2"
Grid.Column="0"
Header="Children">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Margin="8,6,8,0"
Foreground="Gray"
FontSize="11"
Text="Select a child to inspect its stream."/>
<ListBox Margin="8"
ItemsSource="{Binding Children}"
SelectedItem="{Binding SelectedChild}"
DisplayMemberPath="DisplayName"/>
</DockPanel>
</GroupBox>
<Grid Grid.Row="2"
Grid.Column="2">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="12"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
FontSize="14"
FontWeight="SemiBold"
Text="{Binding SelectedChild.StatusLine}"/>
<ProgressBar Grid.Row="1"
Height="18"
Minimum="0"
Maximum="100"
Value="{Binding SelectedChild.ProgressPercent}"/>
<GroupBox Grid.Row="3"
Header="Stream (Log/Progress/Result)">
<ListBox Margin="8"
ItemsSource="{Binding SelectedChild.Logs}"/>
</GroupBox>
<TextBlock Grid.Row="4"
Foreground="Gray"
FontSize="11"
Text="{Binding SelectedChild.PipePath}"/>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,48 @@
using System.Windows;
namespace ParentWpf;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private readonly MainViewModel _vm;
public MainWindow()
{
InitializeComponent();
_vm = new MainViewModel();
DataContext = _vm;
}
protected override async void OnClosed(EventArgs e)
{
base.OnClosed(e);
try { await _vm.DisposeAsync(); } catch { }
}
private async void StartChildren_Click(object sender, RoutedEventArgs e)
{
try { await _vm.StartChildrenAsync(count: 3); }
catch (Exception ex) { MessageBox.Show(this, ex.Message, "Error"); }
}
private async void PingSelected_Click(object sender, RoutedEventArgs e)
{
try { await _vm.PingSelectedAsync(); }
catch (Exception ex) { MessageBox.Show(this, ex.Message, "Error"); }
}
private async void StartWork_Click(object sender, RoutedEventArgs e)
{
try { await _vm.StartWorkSelectedAsync(); }
catch (Exception ex) { MessageBox.Show(this, ex.Message, "Error"); }
}
private async void CancelWork_Click(object sender, RoutedEventArgs e)
{
try { await _vm.CancelWorkSelectedAsync(); }
catch (Exception ex) { MessageBox.Show(this, ex.Message, "Error"); }
}
}

23
ParentWpf/NotifyBase.cs Normal file
View File

@@ -0,0 +1,23 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace ParentWpf;
public abstract class NotifyBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\CommIpc\CommIpc.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>