feat: Add Node.js worker implementation and integrate worker type selection in UI
This commit is contained in:
290
ChildWorkerNode/child-worker.js
Normal file
290
ChildWorkerNode/child-worker.js
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Node.js implementation of the ChildWorker for CommTester.
|
||||||
|
*
|
||||||
|
* Protocol: 4-byte little-endian length prefix + UTF-8 JSON payload
|
||||||
|
*
|
||||||
|
* Usage: node child-worker.js --pipe <pipeName> --id <childId>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const net = require('net');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Argument Parsing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseArgs(args) {
|
||||||
|
let pipeName = null;
|
||||||
|
let childId = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--pipe' && i + 1 < args.length) {
|
||||||
|
pipeName = args[++i];
|
||||||
|
} else if (args[i] === '--id' && i + 1 < args.length) {
|
||||||
|
childId = parseInt(args[++i], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pipeName, childId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pipeName, childId } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (!pipeName || childId === null || isNaN(childId)) {
|
||||||
|
console.error('Usage: node child-worker.js --pipe <pipeName> --id <childId>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IPC Protocol Constants (matching IpcKinds.cs)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const IpcKinds = {
|
||||||
|
Hello: 'hello',
|
||||||
|
Ping: 'ping',
|
||||||
|
Pong: 'pong',
|
||||||
|
StartWork: 'startWork',
|
||||||
|
CancelWork: 'cancelWork',
|
||||||
|
Log: 'log',
|
||||||
|
Progress: 'progress',
|
||||||
|
Result: 'result',
|
||||||
|
Error: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Frame Serialization
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a frame to the socket with 4-byte little-endian length prefix.
|
||||||
|
* @param {net.Socket} socket
|
||||||
|
* @param {object} frame
|
||||||
|
*/
|
||||||
|
function writeFrame(socket, frame) {
|
||||||
|
// Add timestamp if not present
|
||||||
|
if (!frame.timestamp) {
|
||||||
|
frame.timestamp = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = Buffer.from(JSON.stringify(frame), 'utf8');
|
||||||
|
const header = Buffer.alloc(4);
|
||||||
|
header.writeUInt32LE(json.length, 0);
|
||||||
|
|
||||||
|
socket.write(header);
|
||||||
|
socket.write(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a frame reader that emits complete frames.
|
||||||
|
* @returns {{ push: (chunk: Buffer) => void, onFrame: (callback: (frame: object) => void) => void }}
|
||||||
|
*/
|
||||||
|
function createFrameReader() {
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
let frameCallback = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
push(chunk) {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
|
||||||
|
while (buffer.length >= 4) {
|
||||||
|
const length = buffer.readUInt32LE(0);
|
||||||
|
|
||||||
|
if (length <= 0 || length > 4 * 1024 * 1024) {
|
||||||
|
throw new Error(`Invalid frame length: ${length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.length < 4 + length) {
|
||||||
|
break; // Wait for more data
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonBytes = buffer.subarray(4, 4 + length);
|
||||||
|
buffer = buffer.subarray(4 + length);
|
||||||
|
|
||||||
|
const frame = JSON.parse(jsonBytes.toString('utf8'));
|
||||||
|
if (frameCallback) {
|
||||||
|
frameCallback(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFrame(callback) {
|
||||||
|
frameCallback = callback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Work Task Management
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let workAbortController = null;
|
||||||
|
let currentWorkCorrelationId = null;
|
||||||
|
|
||||||
|
async function cancelWork() {
|
||||||
|
if (workAbortController) {
|
||||||
|
workAbortController.abort();
|
||||||
|
workAbortController = null;
|
||||||
|
currentWorkCorrelationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWork(socket, correlationId, steps, delayMs) {
|
||||||
|
await cancelWork();
|
||||||
|
|
||||||
|
currentWorkCorrelationId = correlationId;
|
||||||
|
workAbortController = new AbortController();
|
||||||
|
const signal = workAbortController.signal;
|
||||||
|
|
||||||
|
// Run work in the background
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Log,
|
||||||
|
correlationId,
|
||||||
|
payload: { level: 'info', message: `Child ${childId}: work started (${steps} steps).` }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 1; i <= steps; i++) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(delayMs, signal);
|
||||||
|
|
||||||
|
const percent = (i * 100.0) / steps;
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Progress,
|
||||||
|
correlationId,
|
||||||
|
payload: { step: i, total: steps, percent }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log every ~20% of progress
|
||||||
|
if (i % Math.max(1, Math.floor(steps / 5)) === 0) {
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Log,
|
||||||
|
correlationId,
|
||||||
|
payload: { level: 'debug', message: `Child ${childId}: reached step ${i}/${steps}.` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Result,
|
||||||
|
correlationId,
|
||||||
|
payload: { message: `Child ${childId}: work finished.` }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Error,
|
||||||
|
correlationId,
|
||||||
|
payload: { message: `Child ${childId}: work failed - ${err.message}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentWorkCorrelationId === correlationId) {
|
||||||
|
workAbortController = null;
|
||||||
|
currentWorkCorrelationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms, signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(resolve, ms);
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const pipePath = process.platform === 'win32'
|
||||||
|
? `\\\\.\\pipe\\${pipeName}`
|
||||||
|
: `/tmp/${pipeName}`;
|
||||||
|
|
||||||
|
const socket = net.createConnection(pipePath, () => {
|
||||||
|
// Connection established - send hello
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Hello,
|
||||||
|
payload: { childId, pid: process.pid }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const frameReader = createFrameReader();
|
||||||
|
|
||||||
|
frameReader.onFrame((frame) => {
|
||||||
|
switch (frame.kind) {
|
||||||
|
case IpcKinds.Ping:
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Pong,
|
||||||
|
correlationId: frame.correlationId,
|
||||||
|
payload: { childId }
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IpcKinds.StartWork: {
|
||||||
|
const payload = frame.payload || {};
|
||||||
|
const steps = payload.steps || 25;
|
||||||
|
const delayMs = payload.delayMs || 150;
|
||||||
|
const corr = frame.correlationId || crypto.randomUUID().replace(/-/g, '');
|
||||||
|
startWork(socket, corr, steps, delayMs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case IpcKinds.CancelWork:
|
||||||
|
cancelWork().then(() => {
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Log,
|
||||||
|
correlationId: frame.correlationId || currentWorkCorrelationId,
|
||||||
|
payload: { level: 'info', message: `Child ${childId}: work cancelled.` }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeFrame(socket, {
|
||||||
|
kind: IpcKinds.Error,
|
||||||
|
correlationId: frame.correlationId,
|
||||||
|
payload: { message: `Unknown command kind '${frame.kind}'.` }
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
try {
|
||||||
|
frameReader.push(chunk);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Frame read error:', err.message);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
console.error('Socket error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
cancelWork();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
cancelWork();
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
cancelWork();
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
18
ChildWorkerNode/package.json
Normal file
18
ChildWorkerNode/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "child-worker-node",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Node.js implementation of CommTester ChildWorker",
|
||||||
|
"main": "child-worker.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node child-worker.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ipc",
|
||||||
|
"named-pipe",
|
||||||
|
"child-worker"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,27 @@ using CommIpc;
|
|||||||
|
|
||||||
namespace ParentAvalonia;
|
namespace ParentAvalonia;
|
||||||
|
|
||||||
|
public enum WorkerType
|
||||||
|
{
|
||||||
|
CSharp,
|
||||||
|
NodeJs,
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class MainViewModel : NotifyBase, IAsyncDisposable
|
public sealed class MainViewModel : NotifyBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private ChildSession? _selectedChild;
|
private ChildSession? _selectedChild;
|
||||||
private bool _childrenStarted;
|
private bool _childrenStarted;
|
||||||
|
private WorkerType _selectedWorkerType = WorkerType.CSharp;
|
||||||
private readonly CancellationTokenSource _shutdownCts = new();
|
private readonly CancellationTokenSource _shutdownCts = new();
|
||||||
|
|
||||||
|
public WorkerType[] WorkerTypes { get; } = Enum.GetValues<WorkerType>();
|
||||||
|
|
||||||
|
public WorkerType SelectedWorkerType
|
||||||
|
{
|
||||||
|
get => _selectedWorkerType;
|
||||||
|
set => SetField(ref _selectedWorkerType, value);
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<ChildSession> Children { get; } = new();
|
public ObservableCollection<ChildSession> Children { get; } = new();
|
||||||
|
|
||||||
public ChildSession? SelectedChild
|
public ChildSession? SelectedChild
|
||||||
@@ -110,36 +125,51 @@ public sealed class MainViewModel : NotifyBase, IAsyncDisposable
|
|||||||
_ = Task.Run(() => ReceiveLoopAsync(session), _shutdownCts.Token);
|
_ = Task.Run(() => ReceiveLoopAsync(session), _shutdownCts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Process StartChildProcess(int childId, string pipeName)
|
private Process StartChildProcess(int childId, string pipeName)
|
||||||
{
|
{
|
||||||
// Cross-platform friendly: prefer running the built dll via 'dotnet'.
|
|
||||||
string? solutionRoot = TryFindSolutionRoot(AppContext.BaseDirectory);
|
string? solutionRoot = TryFindSolutionRoot(AppContext.BaseDirectory);
|
||||||
if (solutionRoot is null)
|
if (solutionRoot is null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Could not find solution root (CommTester.slnx).");
|
throw new InvalidOperationException("Could not find solution root (CommTester.slnx).");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 childDll =
|
|
||||||
File.Exists(debugDll) ? debugDll
|
|
||||||
: File.Exists(releaseDll) ? releaseDll
|
|
||||||
: string.Empty;
|
|
||||||
|
|
||||||
string fileName;
|
string fileName;
|
||||||
string arguments;
|
string arguments;
|
||||||
if (!string.IsNullOrWhiteSpace(childDll))
|
|
||||||
|
switch (SelectedWorkerType)
|
||||||
{
|
{
|
||||||
fileName = "dotnet";
|
case WorkerType.NodeJs:
|
||||||
arguments = $"\"{childDll}\" --pipe \"{pipeName}\" --id {childId}";
|
string nodeScript = Path.Combine(solutionRoot, "ChildWorkerNode", "child-worker.js");
|
||||||
}
|
if (!File.Exists(nodeScript))
|
||||||
else
|
{
|
||||||
{
|
throw new FileNotFoundException($"Node.js worker not found: {nodeScript}");
|
||||||
// Last-resort: run via project (slower but robust in fresh checkouts).
|
}
|
||||||
string csproj = Path.Combine(solutionRoot, "ChildWorker", "ChildWorker.csproj");
|
fileName = "node";
|
||||||
fileName = "dotnet";
|
arguments = $"\"{nodeScript}\" --pipe \"{pipeName}\" --id {childId}";
|
||||||
arguments = $"run --project \"{csproj}\" -- --pipe \"{pipeName}\" --id {childId}";
|
break;
|
||||||
|
|
||||||
|
case WorkerType.CSharp:
|
||||||
|
default:
|
||||||
|
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 childDll =
|
||||||
|
File.Exists(debugDll) ? debugDll
|
||||||
|
: File.Exists(releaseDll) ? releaseDll
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(childDll))
|
||||||
|
{
|
||||||
|
fileName = "dotnet";
|
||||||
|
arguments = $"\"{childDll}\" --pipe \"{pipeName}\" --id {childId}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string csproj = Path.Combine(solutionRoot, "ChildWorker", "ChildWorker.csproj");
|
||||||
|
fileName = "dotnet";
|
||||||
|
arguments = $"run --project \"{csproj}\" -- --pipe \"{pipeName}\" --id {childId}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<Grid Margin="12" RowDefinitions="Auto,12,*" ColumnDefinitions="260,12,*">
|
<Grid Margin="12" RowDefinitions="Auto,12,*" ColumnDefinitions="260,12,*">
|
||||||
|
|
||||||
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Orientation="Horizontal" Spacing="8">
|
||||||
|
<ComboBox ItemsSource="{Binding WorkerTypes}"
|
||||||
|
SelectedItem="{Binding SelectedWorkerType}"
|
||||||
|
Width="120" VerticalAlignment="Center" />
|
||||||
<Button Content="Start 3 Children" Padding="12,6" Click="StartChildren_Click" />
|
<Button Content="Start 3 Children" Padding="12,6" Click="StartChildren_Click" />
|
||||||
<Button Content="Ping Selected" Padding="12,6" Click="PingSelected_Click" />
|
<Button Content="Ping Selected" Padding="12,6" Click="PingSelected_Click" />
|
||||||
<Button Content="Start Work" Padding="12,6" Click="StartWork_Click" />
|
<Button Content="Start Work" Padding="12,6" Click="StartWork_Click" />
|
||||||
|
|||||||
Reference in New Issue
Block a user