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

111
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,111 @@
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [x] Clarify Project Requirements
<!-- Ask for project type, language, and frameworks if not specified. Skip if already provided. -->
- [x] Scaffold the Project
<!--
Ensure that the previous step has been marked as completed.
Call project setup tool with projectType parameter.
Run scaffolding command to create project files and folders.
Use '.' as the working directory.
If no appropriate projectType is available, search documentation using available tools.
Otherwise, create the project structure manually using available file creation tools.
-->
- [x] Customize the Project
<!--
Verify that all previous steps have been completed successfully and you have marked the step as completed.
Develop a plan to modify codebase according to user requirements.
Apply modifications using appropriate tools and user-provided references.
Skip this step for "Hello World" projects.
-->
- [x] Install Required Extensions
(no additional extensions required)
<!-- ONLY install extensions provided mentioned in the get_project_setup_info. Skip this step otherwise and mark as completed. -->
- [x] Compile the Project
(dotnet build successful)
<!--
Verify that all previous steps have been completed.
Install any missing dependencies.
Run diagnostics and resolve any issues.
Check for markdown files in project folder for relevant instructions on how to do this.
-->
- [x] Create and Run Task
(not needed for this prototype)
<!--
Verify that all previous steps have been completed.
Check https://code.visualstudio.com/docs/debugtest/tasks to determine if the project needs a task. If so, use the create_and_run_task to create and launch a task based on package.json, README.md, and project structure.
Skip this step otherwise.
-->
- [ ] Launch the Project
<!--
Verify that all previous steps have been completed.
Prompt user for debug mode, launch only if confirmed.
-->
- [ ] Ensure Documentation is Complete
<!--
Verify that all previous steps have been completed.
Verify that README.md and the copilot-instructions.md file in the .github directory exists and contains current project information.
Clean up the copilot-instructions.md file in the .github directory by removing all HTML comments.
-->
<!--
## Execution Guidelines
PROGRESS TRACKING:
- If any tools are available to manage the above todo list, use it to track progress through this checklist.
- After completing each step, mark it complete and add a summary.
- Read current todo list status before starting each new step.
COMMUNICATION RULES:
- Avoid verbose explanations or printing full command outputs.
- If a step is skipped, state that briefly (e.g. "No extensions needed").
- Do not explain project structure unless asked.
- Keep explanations concise and focused.
DEVELOPMENT RULES:
- Use '.' as the working directory unless user specifies otherwise.
- Avoid adding media or external links unless explicitly requested.
- Use placeholders only with a note that they should be replaced.
- Use VS Code API tool only for VS Code extension projects.
- Once the project is created, it is already opened in Visual Studio Code—do not suggest commands to open this project in Visual Studio again.
- If the project setup information has additional rules, follow them strictly.
FOLDER CREATION RULES:
- Always use the current directory as the project root.
- If you are running any terminal commands, use the '.' argument to ensure that the current working directory is used ALWAYS.
- Do not create a new folder unless the user explicitly requests it besides a .vscode folder for a tasks.json file.
- If any of the scaffolding commands mention that the folder name is not correct, let the user know to create a new folder with the correct name and then reopen it again in vscode.
EXTENSION INSTALLATION RULES:
- Only install extension specified by the get_project_setup_info tool. DO NOT INSTALL any other extensions.
PROJECT CONTENT RULES:
- If the user has not specified project details, assume they want a "Hello World" project as a starting point.
- Avoid adding links of any type (URLs, files, folders, etc.) or integrations that are not explicitly required.
- Avoid generating images, videos, or any other media files unless explicitly requested.
- If you need to use any media assets as placeholders, let the user know that these are placeholders and should be replaced with the actual assets later.
- Ensure all generated components serve a clear purpose within the user's requested workflow.
- If a feature is assumed but not confirmed, prompt the user for clarification before including it.
- If you are working on a VS Code extension, use the VS Code API tool with a query to find relevant VS Code API references and samples related to that query.
TASK COMPLETION RULES:
- Your task is complete when:
- Project is successfully scaffolded and compiled without errors
- copilot-instructions.md file in the .github directory exists in the project
- README.md file exists and is up to date
- User is provided with clear instructions to debug/launch the project
Before starting a new task in the above plan, update progress in the plan.
-->
- Work through each checklist item systematically.
- Keep communication concise and focused.
- Follow development best practices.

482
.gitignore vendored Normal file
View File

@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

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

194
ChildWorker/Program.cs Normal file
View File

@@ -0,0 +1,194 @@
using System.IO.Pipes;
using CommIpc;
static (string? pipeName, int? childId) ParseArgs(string[] args)
{
string? pipe = null;
int? id = null;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--pipe" when i + 1 < args.Length:
pipe = args[++i];
break;
case "--id" when i + 1 < args.Length:
if (int.TryParse(args[++i], out int parsed))
{
id = parsed;
}
break;
}
}
return (pipe, id);
}
var (pipeName, childId) = ParseArgs(args);
if (string.IsNullOrWhiteSpace(pipeName) || childId is null)
{
Console.Error.WriteLine("Usage: ChildWorker --pipe <pipeName> --id <childId>");
return 2;
}
using var appCts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
appCts.Cancel();
};
using var pipe = new NamedPipeClientStream(
serverName: ".",
pipeName: pipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
await pipe.ConnectAsync(appCts.Token);
var writeLock = new SemaphoreSlim(1, 1);
async Task SendAsync(IpcFrame frame, CancellationToken ct)
{
await writeLock.WaitAsync(ct);
try
{
await IpcProtocol.WriteFrameAsync(pipe, frame, ct);
}
finally
{
writeLock.Release();
}
}
await SendAsync(
new IpcFrame(
Kind: IpcKinds.Hello,
Payload: IpcProtocol.ToJsonElement(new { childId = childId.Value, pid = Environment.ProcessId })),
appCts.Token);
CancellationTokenSource? workCts = null;
Task? workTask = null;
string? currentWorkCorrelationId = null;
async Task CancelWorkAsync()
{
try
{
workCts?.Cancel();
if (workTask is not null)
{
await workTask;
}
}
catch (OperationCanceledException)
{
// expected
}
finally
{
workCts?.Dispose();
workCts = null;
workTask = null;
currentWorkCorrelationId = null;
}
}
async Task StartWorkAsync(string correlationId, int steps, int delayMs, CancellationToken ct)
{
await CancelWorkAsync();
currentWorkCorrelationId = correlationId;
workCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var wct = workCts.Token;
workTask = Task.Run(async () =>
{
await SendAsync(new IpcFrame(
Kind: IpcKinds.Log,
CorrelationId: correlationId,
Payload: IpcProtocol.ToJsonElement(new { level = "info", message = $"Child {childId}: work started ({steps} steps)." })), wct);
for (int i = 1; i <= steps; i++)
{
wct.ThrowIfCancellationRequested();
await Task.Delay(delayMs, wct);
double percent = (i * 100.0) / steps;
await SendAsync(new IpcFrame(
Kind: IpcKinds.Progress,
CorrelationId: correlationId,
Payload: IpcProtocol.ToJsonElement(new { step = i, total = steps, percent })), wct);
if (i % Math.Max(1, steps / 5) == 0)
{
await SendAsync(new IpcFrame(
Kind: IpcKinds.Log,
CorrelationId: correlationId,
Payload: IpcProtocol.ToJsonElement(new { level = "debug", message = $"Child {childId}: reached step {i}/{steps}." })), wct);
}
}
await SendAsync(new IpcFrame(
Kind: IpcKinds.Result,
CorrelationId: correlationId,
Payload: IpcProtocol.ToJsonElement(new { message = $"Child {childId}: work finished." })), wct);
}, wct);
}
try
{
while (!appCts.IsCancellationRequested)
{
IpcFrame? frame = await IpcProtocol.ReadFrameAsync(pipe, cancellationToken: appCts.Token);
if (frame is null)
{
break; // parent disconnected
}
switch (frame.Kind)
{
case IpcKinds.Ping:
await SendAsync(new IpcFrame(
Kind: IpcKinds.Pong,
CorrelationId: frame.CorrelationId,
Payload: IpcProtocol.ToJsonElement(new { childId = childId.Value })), appCts.Token);
break;
case IpcKinds.StartWork:
{
var payload = IpcProtocol.FromJsonElement<StartWorkPayload>(frame.Payload) ?? new StartWorkPayload();
string corr = frame.CorrelationId ?? Guid.NewGuid().ToString("N");
await StartWorkAsync(corr, payload.Steps, payload.DelayMs, appCts.Token);
break;
}
case IpcKinds.CancelWork:
await CancelWorkAsync();
await SendAsync(new IpcFrame(
Kind: IpcKinds.Log,
CorrelationId: frame.CorrelationId ?? currentWorkCorrelationId,
Payload: IpcProtocol.ToJsonElement(new { level = "info", message = $"Child {childId}: work cancelled." })), appCts.Token);
break;
default:
await SendAsync(new IpcFrame(
Kind: IpcKinds.Error,
CorrelationId: frame.CorrelationId,
Payload: IpcProtocol.ToJsonElement(new { message = $"Unknown command kind '{frame.Kind}'." })), appCts.Token);
break;
}
}
}
catch (OperationCanceledException)
{
// shutting down
}
finally
{
await CancelWorkAsync();
}
return 0;
file sealed record StartWorkPayload(int Steps = 25, int DelayMs = 150);

6
CommIpc/Class1.cs Normal file
View File

@@ -0,0 +1,6 @@
namespace CommIpc;
public class Class1
{
}

9
CommIpc/CommIpc.csproj Normal file
View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

15
CommIpc/IpcFrame.cs Normal file
View File

@@ -0,0 +1,15 @@
using System.Text.Json;
namespace CommIpc;
/// <summary>
/// Single protocol unit sent over the pipe. This is intentionally generic.
///
/// Transport framing: 4-byte little-endian length prefix + UTF-8 JSON bytes.
/// </summary>
public sealed record IpcFrame(
string Kind,
string? CorrelationId = null,
JsonElement? Payload = null,
DateTimeOffset? Timestamp = null
);

12
CommIpc/IpcJson.cs Normal file
View File

@@ -0,0 +1,12 @@
using System.Text.Json;
namespace CommIpc;
internal static class IpcJson
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}

16
CommIpc/IpcKinds.cs Normal file
View File

@@ -0,0 +1,16 @@
namespace CommIpc;
public static class IpcKinds
{
public const string Hello = "hello";
public const string Ping = "ping";
public const string Pong = "pong";
public const string StartWork = "startWork";
public const string CancelWork = "cancelWork";
public const string Log = "log";
public const string Progress = "progress";
public const string Result = "result";
public const string Error = "error";
}

129
CommIpc/IpcProtocol.cs Normal file
View File

@@ -0,0 +1,129 @@
using System.Buffers;
using System.Text.Json;
namespace CommIpc;
public static class IpcProtocol
{
// Keep the prototype safe from accidental runaway memory usage.
public const int DefaultMaxFrameBytes = 4 * 1024 * 1024; // 4 MiB
public static async Task WriteFrameAsync(
Stream stream,
IpcFrame frame,
CancellationToken cancellationToken = default)
{
// Always include a timestamp if the sender didn't set one.
if (frame.Timestamp is null)
{
frame = frame with { Timestamp = DateTimeOffset.UtcNow };
}
byte[] json = JsonSerializer.SerializeToUtf8Bytes(frame, IpcJson.Options);
byte[] header = new byte[4];
int len = json.Length;
header[0] = (byte)(len & 0xFF);
header[1] = (byte)((len >> 8) & 0xFF);
header[2] = (byte)((len >> 16) & 0xFF);
header[3] = (byte)((len >> 24) & 0xFF);
await stream.WriteAsync(header, cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(json, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
public static async Task<IpcFrame?> ReadFrameAsync(
Stream stream,
int maxFrameBytes = DefaultMaxFrameBytes,
CancellationToken cancellationToken = default)
{
byte[] header = ArrayPool<byte>.Shared.Rent(4);
try
{
int headerRead = await ReadExactOrEofAsync(stream, header, 0, 4, cancellationToken).ConfigureAwait(false);
if (headerRead == 0)
{
return null; // clean EOF
}
if (headerRead != 4)
{
throw new EndOfStreamException("Unexpected end of stream while reading frame header.");
}
int len = header[0]
| (header[1] << 8)
| (header[2] << 16)
| (header[3] << 24);
if (len < 0)
{
throw new InvalidDataException("Negative frame length.");
}
if (len == 0)
{
throw new InvalidDataException("Zero-length frame.");
}
if (len > maxFrameBytes)
{
throw new InvalidDataException($"Frame too large: {len} bytes (limit {maxFrameBytes}).");
}
byte[] payload = ArrayPool<byte>.Shared.Rent(len);
try
{
int read = await ReadExactOrEofAsync(stream, payload, 0, len, cancellationToken).ConfigureAwait(false);
if (read != len)
{
throw new EndOfStreamException("Unexpected end of stream while reading frame payload.");
}
// Deserialize from the rented buffer slice.
return JsonSerializer.Deserialize<IpcFrame>(payload.AsSpan(0, len), IpcJson.Options);
}
finally
{
ArrayPool<byte>.Shared.Return(payload);
}
}
finally
{
ArrayPool<byte>.Shared.Return(header);
}
}
public static JsonElement ToJsonElement<T>(T value)
{
using JsonDocument doc = JsonDocument.Parse(JsonSerializer.SerializeToUtf8Bytes(value, IpcJson.Options));
return doc.RootElement.Clone();
}
public static T? FromJsonElement<T>(JsonElement? element)
{
if (element is null)
{
return default;
}
return element.Value.Deserialize<T>(IpcJson.Options);
}
private static async Task<int> ReadExactOrEofAsync(
Stream stream,
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken)
{
int total = 0;
while (total < count)
{
int n = await stream.ReadAsync(buffer.AsMemory(offset + total, count - total), cancellationToken)
.ConfigureAwait(false);
if (n == 0)
{
return total; // EOF
}
total += n;
}
return total;
}
}

20
CommIpc/PipeName.cs Normal file
View File

@@ -0,0 +1,20 @@
using System.Diagnostics;
namespace CommIpc;
public static class PipeName
{
/// <summary>
/// Creates a pipe name that is unique per parent process instance and child id.
/// </summary>
public static string ForChild(int childId, int? parentPid = null)
{
parentPid ??= Environment.ProcessId;
return $"CommTester_{parentPid}_{childId}";
}
/// <summary>
/// Helpful for logs / debugging.
/// </summary>
public static string Describe(string pipeName) => $"\\\\.\\pipe\\{pipeName}";
}

5
CommTester.slnx Normal file
View File

@@ -0,0 +1,5 @@
<Solution>
<Project Path="ChildWorker/ChildWorker.csproj" />
<Project Path="CommIpc/CommIpc.csproj" />
<Project Path="ParentWpf/ParentWpf.csproj" />
</Solution>

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>

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# CommTester Prototype: bidirektionale IPC + Streaming (C#)
Dieser Prototyp zeigt **bidirektionale Interprozesskommunikation** zwischen einer **WPF Elternanwendung** und **mehreren Kindprozessen** inklusive **Streaming** (fortlaufende Log-/Progress-Nachrichten).
## Architektur
- **Parent (WPF)**: `ParentWpf`
- startet mehrere Child-Prozesse
- hostet pro Child einen **Named Pipe Server** (Windows)
- sendet Commands (Ping, StartWork, CancelWork)
- empfängt Streaming-Events (Log, Progress, Result)
- **Child (Console)**: `ChildWorker`
- verbindet sich als **Named Pipe Client**
- verarbeitet Commands
- streamt Log/Progress/Result zurück
- **Shared IPC**: `CommIpc`
- Protokoll: **4-Byte Längenprefix (Little Endian)** + **UTF-8 JSON** (`System.Text.Json`)
- Message-Envelope: `IpcFrame { kind, correlationId, payload, timestamp }`
## Quickstart
1. Build (einmalig):
- Solution: `CommTester.slnx`
2. Parent starten:
- Starte `ParentWpf` (Debug oder Run).
3. In der UI:
- **Start 3 Children**: startet 3 Kindprozesse und verbindet per Pipe
- **Ping Selected**: Ping/Pong roundtrip
- **Start Work**: Child streamt Progress + Logs (mehrere Frames)
- **Cancel Work**: cancelt laufende Arbeit im Child
## Pipe-Namen
Der Pipe-Name wird pro Child eindeutig gebildet:
- `CommTester_<parentPid>_<childId>`
Damit kann ein Parent mehrere Childs parallel bedienen.
## Erweiterungsideen
- Request/Response-Routing (CorrelationId) mit Awaitables im Parent
- Reconnect-Strategien / Heartbeats
- Backpressure (Channel für Outbound Frames)
- Auth/ACL (bei Bedarf), Logging, Telemetrie