0
0
Fork 0
Codesys-MCP-SP21-plus/src/ipc.ts
Luke e374519fe1 Initial release: MCP server for CODESYS with persistent UI instance
v0.3.0 returning-watcher architecture — background thread polls for commands
and marshals execution onto the CODESYS UI thread, keeping the IDE fully
responsive between operations. File-based IPC with atomic writes, async mutex
command serialization, headless fallback, and 35 passing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:59:16 +10:00

239 lines
7.1 KiB
TypeScript

/**
* File-based IPC transport layer.
* Writes command files, polls for result files, with command serialization.
*/
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { IpcConfig, IpcResult, IpcCommand, RequestId } from './types';
import { ipcLog } from './logger';
/** Default IPC configuration */
export const DEFAULT_IPC_CONFIG: Omit<IpcConfig, 'baseDir'> = {
commandTimeoutMs: 60_000,
pollIntervalMs: 100,
maxPollIntervalMs: 1_000,
deleteResultAfterRead: true,
};
/**
* Async mutex for serializing commands.
* Ensures only one command is in-flight at a time.
*/
class AsyncMutex {
private _queue: Array<() => void> = [];
private _locked = false;
async acquire(): Promise<void> {
if (!this._locked) {
this._locked = true;
return;
}
return new Promise<void>((resolve) => {
this._queue.push(resolve);
});
}
release(): void {
if (this._queue.length > 0) {
const next = this._queue.shift()!;
next();
} else {
this._locked = false;
}
}
}
/**
* Atomic file write: write to .tmp then rename.
* Uses fsync to ensure data is flushed to disk before rename.
*/
async function atomicWrite(filePath: string, content: string): Promise<void> {
const tmpPath = filePath + '.tmp';
const fd = fs.openSync(tmpPath, 'w');
try {
fs.writeSync(fd, content, undefined, 'utf-8');
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmpPath, filePath);
}
export class IpcClient {
private config: IpcConfig;
private mutex = new AsyncMutex();
private commandsDir: string;
private resultsDir: string;
constructor(config: IpcConfig) {
this.config = config;
this.commandsDir = path.join(config.baseDir, 'commands');
this.resultsDir = path.join(config.baseDir, 'results');
}
/** Create commands/ and results/ directories */
async ensureDirectories(): Promise<void> {
fs.mkdirSync(this.commandsDir, { recursive: true });
fs.mkdirSync(this.resultsDir, { recursive: true });
ipcLog.debug(`IPC directories created at ${this.config.baseDir}`);
}
/** Check if the watcher has written ready.signal */
async isReady(): Promise<boolean> {
const signalPath = path.join(this.config.baseDir, 'ready.signal');
return fs.existsSync(signalPath);
}
/** Write terminate.signal to request watcher shutdown */
async sendTerminate(): Promise<void> {
const signalPath = path.join(this.config.baseDir, 'terminate.signal');
await atomicWrite(signalPath, JSON.stringify({ timestamp: Date.now() }));
ipcLog.info('Terminate signal sent');
}
/**
* Send a command to the watcher and wait for result.
* Serialized via async mutex — only one command in-flight at a time.
*/
async sendCommand(scriptContent: string, timeoutMs?: number): Promise<IpcResult> {
await this.mutex.acquire();
try {
return await this._sendCommandInternal(scriptContent, timeoutMs);
} finally {
this.mutex.release();
}
}
private async _sendCommandInternal(
scriptContent: string,
timeoutMs?: number
): Promise<IpcResult> {
const requestId: RequestId = uuidv4();
const timeout = timeoutMs ?? this.config.commandTimeoutMs;
const scriptFileName = `${requestId}.py`;
const commandFileName = `${requestId}.command.json`;
const resultFileName = `${requestId}.result.json`;
const scriptPath = path.join(this.commandsDir, scriptFileName);
const commandPath = path.join(this.commandsDir, commandFileName);
const resultPath = path.join(this.resultsDir, resultFileName);
ipcLog.debug(`Sending command ${requestId}`);
// Step 1: Write .py script file with fsync
await atomicWrite(scriptPath, scriptContent);
// Step 2: Write .command.json (triggers watcher)
const command: IpcCommand = {
requestId,
scriptPath: scriptPath,
timestamp: Date.now(),
};
await atomicWrite(commandPath, JSON.stringify(command));
ipcLog.debug(`Command ${requestId} written, polling for result...`);
// Step 3: Poll for result with progressive backoff
const startTime = Date.now();
let pollInterval = this.config.pollIntervalMs;
while (Date.now() - startTime < timeout) {
if (fs.existsSync(resultPath)) {
// Try to read result with retry for partial writes
const result = await this._readResultWithRetry(resultPath, requestId);
if (result) {
// Clean up result file if configured
if (this.config.deleteResultAfterRead) {
try {
fs.unlinkSync(resultPath);
} catch {
// Ignore cleanup errors
}
}
ipcLog.debug(
`Command ${requestId} completed: success=${result.success}`
);
return result;
}
}
// Progressive backoff: double interval, cap at max
await this._sleep(pollInterval);
pollInterval = Math.min(pollInterval * 2, this.config.maxPollIntervalMs);
}
// Timeout — clean up command files
this._cleanupCommandFiles(requestId);
throw new Error(
`Command ${requestId} timed out after ${timeout}ms waiting for result`
);
}
/**
* Read result file with retry for corrupted/partial JSON.
* Up to 3 attempts with 100ms delay between.
*/
private async _readResultWithRetry(
resultPath: string,
requestId: string
): Promise<IpcResult | null> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const content = fs.readFileSync(resultPath, 'utf-8');
const result: IpcResult = JSON.parse(content);
if (result.requestId === requestId) {
return result;
}
ipcLog.warn(
`Result file requestId mismatch: expected ${requestId}, got ${result.requestId}`
);
return null;
} catch (err) {
if (attempt < 2) {
ipcLog.debug(
`Result read attempt ${attempt + 1} failed, retrying in 100ms...`
);
await this._sleep(100);
} else {
ipcLog.warn(
`Failed to read result after 3 attempts for ${requestId}: ${err}`
);
return null;
}
}
}
return null;
}
/** Clean up command files for a given request */
private _cleanupCommandFiles(requestId: string): void {
const scriptPath = path.join(this.commandsDir, `${requestId}.py`);
const commandPath = path.join(
this.commandsDir,
`${requestId}.command.json`
);
try {
if (fs.existsSync(scriptPath)) fs.unlinkSync(scriptPath);
} catch { /* ignore */ }
try {
if (fs.existsSync(commandPath)) fs.unlinkSync(commandPath);
} catch { /* ignore */ }
}
/** Remove the entire session directory */
async cleanup(): Promise<void> {
try {
fs.rmSync(this.config.baseDir, { recursive: true, force: true });
ipcLog.info(`Session directory cleaned up: ${this.config.baseDir}`);
} catch (err) {
ipcLog.warn(`Failed to clean up session directory: ${err}`);
}
}
private _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}