Skip to content

Commit 142cc7c

Browse files
authored
Merge pull request #489 from GaneshPatil7517/fix/concorekill-pid-registry-v2
fix: replace concorekill.bat single-PID overwrite with safe PID registry (#391)
2 parents 7adf163 + 26aa8f8 commit 142cc7c

2 files changed

Lines changed: 255 additions & 6 deletions

File tree

concore.py

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,122 @@
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+
_LOCK_LEN = 0x7FFFFFFF # lock range large enough to cover entire file
30+
_BASE_DIR = os.path.abspath(".") # capture CWD before atexit can shift it
31+
_PID_REGISTRY_FILE = os.path.join(_BASE_DIR, "concorekill_pids.txt")
32+
_KILL_SCRIPT_FILE = os.path.join(_BASE_DIR, "concorekill.bat")
33+
34+
def _register_pid():
35+
"""Append current PID to the shared registry file. Uses file locking on Windows."""
36+
try:
37+
with open(_PID_REGISTRY_FILE, "a") as f:
38+
if hasattr(sys, 'getwindowsversion'):
39+
import msvcrt
40+
try:
41+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, _LOCK_LEN)
42+
f.write(str(os.getpid()) + "\n")
43+
finally:
44+
try:
45+
f.seek(0)
46+
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, _LOCK_LEN)
47+
except OSError:
48+
pass
49+
else:
50+
f.write(str(os.getpid()) + "\n")
51+
except OSError:
52+
pass
53+
54+
def _cleanup_pid():
55+
"""Remove current PID from registry on exit. Uses file locking on Windows."""
56+
pid = str(os.getpid())
57+
try:
58+
if not os.path.exists(_PID_REGISTRY_FILE):
59+
return
60+
with open(_PID_REGISTRY_FILE, "r+") as f:
61+
if hasattr(sys, 'getwindowsversion'):
62+
import msvcrt
63+
try:
64+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, _LOCK_LEN)
65+
pids = [line.strip() for line in f if line.strip()]
66+
remaining = [p for p in pids if p != pid]
67+
if remaining:
68+
f.seek(0)
69+
f.truncate()
70+
for p in remaining:
71+
f.write(p + "\n")
72+
else:
73+
f.close()
74+
try:
75+
os.remove(_PID_REGISTRY_FILE)
76+
except OSError:
77+
pass
78+
try:
79+
os.remove(_KILL_SCRIPT_FILE)
80+
except OSError:
81+
pass
82+
return
83+
finally:
84+
try:
85+
f.seek(0)
86+
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, _LOCK_LEN)
87+
except (OSError, ValueError):
88+
pass
89+
else:
90+
pids = [line.strip() for line in f if line.strip()]
91+
remaining = [p for p in pids if p != pid]
92+
if remaining:
93+
f.seek(0)
94+
f.truncate()
95+
for p in remaining:
96+
f.write(p + "\n")
97+
else:
98+
f.close()
99+
try:
100+
os.remove(_PID_REGISTRY_FILE)
101+
except OSError:
102+
pass
103+
try:
104+
os.remove(_KILL_SCRIPT_FILE)
105+
except OSError:
106+
pass
107+
except OSError:
108+
pass
109+
110+
def _write_kill_script():
111+
"""Generate concorekill.bat that validates each PID before killing."""
112+
try:
113+
reg_name = os.path.basename(_PID_REGISTRY_FILE)
114+
bat_name = os.path.basename(_KILL_SCRIPT_FILE)
115+
script = "@echo off\r\n"
116+
script += 'if not exist "%~dp0' + reg_name + '" (\r\n'
117+
script += " echo No PID registry found. Nothing to kill.\r\n"
118+
script += " exit /b 0\r\n"
119+
script += ")\r\n"
120+
script += 'for /f "usebackq tokens=*" %%p in ("%~dp0' + reg_name + '") do (\r\n'
121+
script += ' wmic process where "ProcessId=%%p" get CommandLine /value 2>nul | find /i "concore" >nul\r\n'
122+
script += " if not errorlevel 1 (\r\n"
123+
script += " echo Killing concore process %%p\r\n"
124+
script += " taskkill /F /PID %%p >nul 2>&1\r\n"
125+
script += " ) else (\r\n"
126+
script += " echo Skipping PID %%p - not a concore process or not running\r\n"
127+
script += " )\r\n"
128+
script += ")\r\n"
129+
script += 'del /q "%~dp0' + reg_name + '" 2>nul\r\n'
130+
script += 'del /q "%~dp0' + bat_name + '" 2>nul\r\n'
131+
with open(_KILL_SCRIPT_FILE, "w", newline="") as f:
132+
f.write(script)
133+
except OSError:
134+
pass
135+
28136
if hasattr(sys, 'getwindowsversion'):
29-
with open("concorekill.bat","w") as fpid:
30-
fpid.write("taskkill /F /PID "+str(os.getpid())+"\n")
137+
_register_pid()
138+
_write_kill_script()
139+
atexit.register(_cleanup_pid)
31140

32141
ZeroMQPort = concore_base.ZeroMQPort
33142
convert_numpy_to_python = concore_base.convert_numpy_to_python

tests/test_concore.py

Lines changed: 140 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
from unittest.mock import patch
56

@@ -579,3 +580,142 @@ def send_json_with_retry(self, message):
579580
concore.simtime = orig_simtime
580581
elif hasattr(concore, "simtime"):
581582
delattr(concore, "simtime")
583+
584+
585+
class TestPidRegistry:
586+
"""Tests for the Windows PID registry mechanism (Issue #391)."""
587+
588+
@pytest.fixture(autouse=True)
589+
def use_temp_dir(self, temp_dir, monkeypatch):
590+
self.temp_dir = temp_dir
591+
monkeypatch.chdir(temp_dir)
592+
import concore
593+
594+
monkeypatch.setattr(concore, "_BASE_DIR", temp_dir)
595+
monkeypatch.setattr(
596+
concore,
597+
"_PID_REGISTRY_FILE",
598+
os.path.join(temp_dir, "concorekill_pids.txt"),
599+
)
600+
monkeypatch.setattr(
601+
concore,
602+
"_KILL_SCRIPT_FILE",
603+
os.path.join(temp_dir, "concorekill.bat"),
604+
)
605+
606+
def test_register_pid_creates_registry_file(self):
607+
from concore import _register_pid, _PID_REGISTRY_FILE
608+
609+
_register_pid()
610+
assert os.path.exists(_PID_REGISTRY_FILE)
611+
with open(_PID_REGISTRY_FILE) as f:
612+
pids = [line.strip() for line in f if line.strip()]
613+
assert str(os.getpid()) in pids
614+
615+
def test_register_pid_appends_not_overwrites(self):
616+
from concore import _register_pid, _PID_REGISTRY_FILE
617+
618+
with open(_PID_REGISTRY_FILE, "w") as f:
619+
f.write("11111\n")
620+
f.write("22222\n")
621+
_register_pid()
622+
with open(_PID_REGISTRY_FILE) as f:
623+
pids = [line.strip() for line in f if line.strip()]
624+
assert "11111" in pids
625+
assert "22222" in pids
626+
assert str(os.getpid()) in pids
627+
assert len(pids) == 3
628+
629+
def test_cleanup_pid_removes_current_pid(self):
630+
from concore import _cleanup_pid, _PID_REGISTRY_FILE
631+
632+
current_pid = str(os.getpid())
633+
with open(_PID_REGISTRY_FILE, "w") as f:
634+
f.write("99999\n")
635+
f.write(current_pid + "\n")
636+
f.write("88888\n")
637+
_cleanup_pid()
638+
with open(_PID_REGISTRY_FILE) as f:
639+
pids = [line.strip() for line in f if line.strip()]
640+
assert current_pid not in pids
641+
assert "99999" in pids
642+
assert "88888" in pids
643+
644+
def test_cleanup_pid_deletes_files_when_last_pid(self):
645+
from concore import _cleanup_pid, _PID_REGISTRY_FILE, _KILL_SCRIPT_FILE
646+
647+
current_pid = str(os.getpid())
648+
with open(_PID_REGISTRY_FILE, "w") as f:
649+
f.write(current_pid + "\n")
650+
with open(_KILL_SCRIPT_FILE, "w") as f:
651+
f.write("@echo off\n")
652+
_cleanup_pid()
653+
assert not os.path.exists(_PID_REGISTRY_FILE)
654+
assert not os.path.exists(_KILL_SCRIPT_FILE)
655+
656+
def test_cleanup_pid_handles_missing_registry(self):
657+
from concore import _cleanup_pid, _PID_REGISTRY_FILE
658+
659+
assert not os.path.exists(_PID_REGISTRY_FILE)
660+
_cleanup_pid() # Should not raise
661+
662+
def test_write_kill_script_generates_bat_file(self):
663+
from concore import _write_kill_script, _KILL_SCRIPT_FILE, _PID_REGISTRY_FILE
664+
665+
_write_kill_script()
666+
assert os.path.exists(_KILL_SCRIPT_FILE)
667+
with open(_KILL_SCRIPT_FILE) as f:
668+
content = f.read()
669+
assert os.path.basename(_PID_REGISTRY_FILE) in content
670+
assert "wmic" in content
671+
assert "taskkill" in content
672+
assert "concore" in content.lower()
673+
674+
def test_multi_node_registration(self):
675+
from concore import _register_pid, _PID_REGISTRY_FILE
676+
677+
fake_pids = ["1204", "1932", "8120"]
678+
with open(_PID_REGISTRY_FILE, "w") as f:
679+
for pid in fake_pids:
680+
f.write(pid + "\n")
681+
_register_pid()
682+
with open(_PID_REGISTRY_FILE) as f:
683+
pids = [line.strip() for line in f if line.strip()]
684+
for pid in fake_pids:
685+
assert pid in pids
686+
assert str(os.getpid()) in pids
687+
assert len(pids) == 4
688+
689+
def test_cleanup_preserves_other_pids(self):
690+
from concore import _cleanup_pid, _PID_REGISTRY_FILE
691+
692+
current_pid = str(os.getpid())
693+
other_pids = ["1111", "2222", "3333"]
694+
with open(_PID_REGISTRY_FILE, "w") as f:
695+
for pid in other_pids:
696+
f.write(pid + "\n")
697+
f.write(current_pid + "\n")
698+
_cleanup_pid()
699+
with open(_PID_REGISTRY_FILE) as f:
700+
pids = [line.strip() for line in f if line.strip()]
701+
assert len(pids) == 3
702+
assert current_pid not in pids
703+
for pid in other_pids:
704+
assert pid in pids
705+
706+
@pytest.mark.skipif(
707+
not hasattr(sys, "getwindowsversion"), reason="Windows-only test"
708+
)
709+
def test_import_registers_pid_on_windows(self):
710+
"""Verify module-level PID registration on Windows."""
711+
import importlib
712+
713+
for mod_name in ("concore", "concore_base"):
714+
sys.modules.pop(mod_name, None)
715+
import concore
716+
717+
assert os.path.exists(concore._PID_REGISTRY_FILE)
718+
with open(concore._PID_REGISTRY_FILE) as f:
719+
pids = [line.strip() for line in f if line.strip()]
720+
assert str(os.getpid()) in pids
721+
importlib.reload(concore)

0 commit comments

Comments
 (0)