0
0
Fork 0

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 <user> on <host>" 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.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-25 21:04:31 +02:00
parent e6cfa5730b
commit 95a884bf1e

View file

@ -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);