From 95a884bf1e7ed459dfaf628777700a6326cad3e5 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Sat, 25 Apr 2026 21:04:31 +0200 Subject: [PATCH] fix(launcher): refuse to spawn when a CODESYS.exe is already running The launcher only knew about its own state machine -- so any time a prior CODESYS.exe was still alive (orphan from a crashed MCP session, the user's own interactive IDE, or a CODESYS still mid-shutdown), calling launch() happily spawned a SECOND CODESYS. The two instances then raced on the project file lock and the loser surfaced the "project is currently in use by on " modal, which blocks the IDE thread and freezes every subsequent script call -- the agent keeps timing out at 60s with no useful diagnostic. Hit twice in a single session on 2026-04-25 during git_* smoke tests: the user pointed at the running taskbar twice ("you are opening shitloads of codesys sessions" / "you keep opening TWO codesyses -- then this message comes up and halts all your progress"). Adds findRunningCodesysPids() -- a tasklist-based scan -- and a pre-spawn guard at the top of launch() that returns a clear, action-oriented error listing the offending PIDs and the two remediations (close the IDE manually, or call shutdown_codesys if the launcher owns the existing process). No-op on non-Windows. Doesn't try to adopt the existing process: that would require sharing its IPC dir + watcher state, which the launcher cannot recover after the fact. Refusing the spawn is the only safe response. --- src/launcher.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/launcher.ts b/src/launcher.ts index 714d1fd..52f7730 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { spawn, ChildProcess } from 'child_process'; +import { spawn, execSync, ChildProcess } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import { LauncherConfig, LauncherStatus, CodesysState, IpcResult, ScriptExecutor } from './types'; import { IpcClient, DEFAULT_IPC_CONFIG } from './ipc'; @@ -14,6 +14,40 @@ import { ScriptManager } from './script-manager'; import { launcherLog } from './logger'; const SESSION_DIR_PREFIX = 'codesys-mcp-persistent'; + +/** + * Returns the PIDs of every CODESYS.exe currently running on this Windows + * machine -- whether spawned by this launcher, by a previous MCP session that + * crashed without cleanup, or by the user opening CODESYS interactively. + * + * Used as a pre-launch guard so the launcher never spawns a second CODESYS + * alongside an existing one. Two CODESYS instances against the same project + * file race on the lock and the loser pops a "project is currently in use" + * modal that freezes the IDE thread, breaking every subsequent script call + * with 60s timeouts. The cheapest fix is to refuse the duplicate spawn. + * + * Returns an empty list on non-Windows or if tasklist fails (we treat that + * as "can't tell" rather than blocking; the user always retains the option + * to close manually). + */ +function findRunningCodesysPids(): number[] { + if (process.platform !== 'win32') return []; + try { + const out = execSync( + 'tasklist /FI "IMAGENAME eq CODESYS.exe" /FO CSV /NH', + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] } + ); + const pids: number[] = []; + for (const line of out.split(/\r?\n/)) { + // CSV with quotes: "CODESYS.exe","12345","Console","1","456,789 K" + const m = line.match(/^"CODESYS\.exe","(\d+)"/); + if (m) pids.push(Number(m[1])); + } + return pids; + } catch { + return []; + } +} const READY_TIMEOUT_MS = 60_000; const READY_POLL_MS = 500; const SHUTDOWN_WAIT_MS = 5_000; @@ -51,6 +85,29 @@ export class CodesysLauncher implements ScriptExecutor { throw new Error(err); } + // Refuse to spawn alongside an existing CODESYS.exe. Two instances against + // the same project file race on the lock and the loser pops a modal that + // freezes script execution. This catches: + // - orphans from a prior MCP session that crashed (exit code 0) without + // taking the IDE down with it + // - the user's own interactive CODESYS instance + // - a CODESYS still mid-shutdown after a previous shutdown_codesys call + const existingPids = findRunningCodesysPids(); + if (existingPids.length > 0) { + const msg = + `Refusing to launch: ${existingPids.length} CODESYS.exe process(es) ` + + `already running (PID(s): ${existingPids.join(', ')}). The MCP launcher ` + + `cannot share IPC with an instance it did not spawn, and a second ` + + `CODESYS racing on the same project triggers a "project is currently ` + + `in use" modal that blocks all script execution. Close the existing ` + + `CODESYS window(s) first, or call shutdown_codesys if this server owns ` + + `the running instance, then retry.`; + launcherLog.warn(msg); + this.lastError = msg; + this.setState('error'); + throw new Error(msg); + } + this.setState('launching'); this.sessionId = uuidv4(); this.ipcDir = path.join(os.tmpdir(), SESSION_DIR_PREFIX, this.sessionId);