Skip to content

Commit f674145

Browse files
fix: replace concorekill.bat single-PID overwrite with safe PID registry (#391)
On Windows, concore.py overwrote concorekill.bat at import time with a single PID, creating race conditions when multiple nodes launched simultaneously and leaving stale PIDs after crashes. Replace with append-based PID registry (concorekill_pids.txt): - _register_pid(): appends current PID (append mode, no overwrite) - _cleanup_pid(): removes current PID on exit with file locking - _write_kill_script(): generates concorekill.bat that validates each PID via tasklist before issuing taskkill Users still run concorekill.bat as before (backward compatible).
1 parent 01e0ec0 commit f674145

2 files changed

Lines changed: 200 additions & 6 deletions

File tree

concore.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,80 @@
2121
logging.getLogger('requests').setLevel(logging.WARNING)
2222

2323

24-
# if windows, create script to kill this process
25-
# because batch files don't provide easy way to know pid of last command
26-
# ignored for posix != windows, because "concorepid" is handled by script
27-
# ignored for docker (linux != windows), because handled by docker stop
24+
# if windows, register this process PID for safe termination
25+
# Previous approach: single "concorekill.bat" overwritten by each node (race condition).
26+
# New approach: append PID to shared registry; generate validated kill script.
27+
# See: https://github.com/ControlCore-Project/concore/issues/391
28+
29+
_PID_REGISTRY_FILE = "concorekill_pids.txt"
30+
_KILL_SCRIPT_FILE = "concorekill.bat"
31+
32+
def _register_pid():
33+
"""Append current PID to the shared registry file."""
34+
try:
35+
with open(_PID_REGISTRY_FILE, "a") as f:
36+
f.write(str(os.getpid()) + "\n")
37+
except OSError:
38+
pass
39+
40+
def _cleanup_pid():
41+
"""Remove current PID from registry on exit. Uses file locking on Windows."""
42+
pid = str(os.getpid())
43+
try:
44+
if not os.path.exists(_PID_REGISTRY_FILE):
45+
return
46+
with open(_PID_REGISTRY_FILE, "r+") as f:
47+
if hasattr(sys, 'getwindowsversion'):
48+
import msvcrt
49+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
50+
pids = [line.strip() for line in f if line.strip()]
51+
remaining = [p for p in pids if p != pid]
52+
if remaining:
53+
f.seek(0)
54+
f.truncate()
55+
for p in remaining:
56+
f.write(p + "\n")
57+
else:
58+
f.close()
59+
try:
60+
os.remove(_PID_REGISTRY_FILE)
61+
except OSError:
62+
pass
63+
try:
64+
os.remove(_KILL_SCRIPT_FILE)
65+
except OSError:
66+
pass
67+
except OSError:
68+
pass
69+
70+
def _write_kill_script():
71+
"""Generate concorekill.bat that validates each PID before killing."""
72+
try:
73+
script = "@echo off\r\n"
74+
script += 'if not exist "%~dp0' + _PID_REGISTRY_FILE + '" (\r\n'
75+
script += " echo No PID registry found. Nothing to kill.\r\n"
76+
script += " exit /b 0\r\n"
77+
script += ")\r\n"
78+
script += 'for /f "usebackq tokens=*" %%p in ("%~dp0' + _PID_REGISTRY_FILE + '") do (\r\n'
79+
script += ' tasklist /FI "PID eq %%p" 2>nul | find /i "python" >nul\r\n'
80+
script += " if not errorlevel 1 (\r\n"
81+
script += " echo Killing Python process %%p\r\n"
82+
script += " taskkill /F /PID %%p >nul 2>&1\r\n"
83+
script += " ) else (\r\n"
84+
script += " echo Skipping PID %%p - not a Python process or not running\r\n"
85+
script += " )\r\n"
86+
script += ")\r\n"
87+
script += 'del /q "%~dp0' + _PID_REGISTRY_FILE + '" 2>nul\r\n'
88+
script += 'del /q "%~dp0' + _KILL_SCRIPT_FILE + '" 2>nul\r\n'
89+
with open(_KILL_SCRIPT_FILE, "w", newline="") as f:
90+
f.write(script)
91+
except OSError:
92+
pass
93+
2894
if hasattr(sys, 'getwindowsversion'):
29-
with open("concorekill.bat","w") as fpid:
30-
fpid.write("taskkill /F /PID "+str(os.getpid())+"\n")
95+
_register_pid()
96+
_write_kill_script()
97+
atexit.register(_cleanup_pid)
3198

3299
ZeroMQPort = concore_base.ZeroMQPort
33100
convert_numpy_to_python = concore_base.convert_numpy_to_python

tests/test_concore.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
import os
3+
import sys
34
import numpy as np
45

56

@@ -450,3 +451,129 @@ def test_write_timestamp_matches_cpp_semantics(self, temp_dir):
450451
"After 3 writes with delta=1 simtime must remain 0 "
451452
"(matching C++/MATLAB/Verilog); got %s" % concore.simtime
452453
)
454+
455+
456+
class TestPidRegistry:
457+
"""Tests for the Windows PID registry mechanism (Issue #391)."""
458+
459+
@pytest.fixture(autouse=True)
460+
def use_temp_dir(self, temp_dir, monkeypatch):
461+
self.temp_dir = temp_dir
462+
monkeypatch.chdir(temp_dir)
463+
464+
def test_register_pid_creates_registry_file(self):
465+
from concore import _register_pid, _PID_REGISTRY_FILE
466+
467+
_register_pid()
468+
assert os.path.exists(_PID_REGISTRY_FILE)
469+
with open(_PID_REGISTRY_FILE) as f:
470+
pids = [line.strip() for line in f if line.strip()]
471+
assert str(os.getpid()) in pids
472+
473+
def test_register_pid_appends_not_overwrites(self):
474+
from concore import _register_pid, _PID_REGISTRY_FILE
475+
476+
with open(_PID_REGISTRY_FILE, "w") as f:
477+
f.write("11111\n")
478+
f.write("22222\n")
479+
_register_pid()
480+
with open(_PID_REGISTRY_FILE) as f:
481+
pids = [line.strip() for line in f if line.strip()]
482+
assert "11111" in pids
483+
assert "22222" in pids
484+
assert str(os.getpid()) in pids
485+
assert len(pids) == 3
486+
487+
def test_cleanup_pid_removes_current_pid(self):
488+
from concore import _cleanup_pid, _PID_REGISTRY_FILE
489+
490+
current_pid = str(os.getpid())
491+
with open(_PID_REGISTRY_FILE, "w") as f:
492+
f.write("99999\n")
493+
f.write(current_pid + "\n")
494+
f.write("88888\n")
495+
_cleanup_pid()
496+
with open(_PID_REGISTRY_FILE) as f:
497+
pids = [line.strip() for line in f if line.strip()]
498+
assert current_pid not in pids
499+
assert "99999" in pids
500+
assert "88888" in pids
501+
502+
def test_cleanup_pid_deletes_files_when_last_pid(self):
503+
from concore import _cleanup_pid, _PID_REGISTRY_FILE, _KILL_SCRIPT_FILE
504+
505+
current_pid = str(os.getpid())
506+
with open(_PID_REGISTRY_FILE, "w") as f:
507+
f.write(current_pid + "\n")
508+
with open(_KILL_SCRIPT_FILE, "w") as f:
509+
f.write("@echo off\n")
510+
_cleanup_pid()
511+
assert not os.path.exists(_PID_REGISTRY_FILE)
512+
assert not os.path.exists(_KILL_SCRIPT_FILE)
513+
514+
def test_cleanup_pid_handles_missing_registry(self):
515+
from concore import _cleanup_pid, _PID_REGISTRY_FILE
516+
517+
assert not os.path.exists(_PID_REGISTRY_FILE)
518+
_cleanup_pid() # Should not raise
519+
520+
def test_write_kill_script_generates_bat_file(self):
521+
from concore import _write_kill_script, _KILL_SCRIPT_FILE, _PID_REGISTRY_FILE
522+
523+
_write_kill_script()
524+
assert os.path.exists(_KILL_SCRIPT_FILE)
525+
with open(_KILL_SCRIPT_FILE) as f:
526+
content = f.read()
527+
assert _PID_REGISTRY_FILE in content
528+
assert "tasklist" in content
529+
assert "taskkill" in content
530+
assert "python" in content.lower()
531+
532+
def test_multi_node_registration(self):
533+
from concore import _register_pid, _PID_REGISTRY_FILE
534+
535+
fake_pids = ["1204", "1932", "8120"]
536+
with open(_PID_REGISTRY_FILE, "w") as f:
537+
for pid in fake_pids:
538+
f.write(pid + "\n")
539+
_register_pid()
540+
with open(_PID_REGISTRY_FILE) as f:
541+
pids = [line.strip() for line in f if line.strip()]
542+
for pid in fake_pids:
543+
assert pid in pids
544+
assert str(os.getpid()) in pids
545+
assert len(pids) == 4
546+
547+
def test_cleanup_preserves_other_pids(self):
548+
from concore import _cleanup_pid, _PID_REGISTRY_FILE
549+
550+
current_pid = str(os.getpid())
551+
other_pids = ["1111", "2222", "3333"]
552+
with open(_PID_REGISTRY_FILE, "w") as f:
553+
for pid in other_pids:
554+
f.write(pid + "\n")
555+
f.write(current_pid + "\n")
556+
_cleanup_pid()
557+
with open(_PID_REGISTRY_FILE) as f:
558+
pids = [line.strip() for line in f if line.strip()]
559+
assert len(pids) == 3
560+
assert current_pid not in pids
561+
for pid in other_pids:
562+
assert pid in pids
563+
564+
@pytest.mark.skipif(
565+
not hasattr(sys, "getwindowsversion"), reason="Windows-only test"
566+
)
567+
def test_import_registers_pid_on_windows(self):
568+
"""Verify module-level PID registration on Windows."""
569+
import importlib
570+
571+
for mod_name in ("concore", "concore_base"):
572+
sys.modules.pop(mod_name, None)
573+
import concore
574+
575+
assert os.path.exists(concore._PID_REGISTRY_FILE)
576+
with open(concore._PID_REGISTRY_FILE) as f:
577+
pids = [line.strip() for line in f if line.strip()]
578+
assert str(os.getpid()) in pids
579+
importlib.reload(concore)

0 commit comments

Comments
 (0)