Skip to content

Commit 7fc7d53

Browse files
fix: address PR review - add file locking, fix path quoting, fix line endings, format with ruff
- Add msvcrt.locking in _cleanup_pid() to prevent race conditions when multiple nodes exit concurrently (Windows file lock) - Use 'usebackq' and quote %~dp0 path in for /f loop so kill script works in directories with spaces/parentheses - Open kill script with newline='' to prevent double-CR (\r\r\n) - Improve test_import_registers_pid_on_windows to actually test import-time side effect by forcing module reimport - Run ruff format on test_concore.py and test_read_status.py
1 parent d82fe38 commit 7fc7d53

3 files changed

Lines changed: 132 additions & 60 deletions

File tree

concore.py

Lines changed: 67 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111

1212
import concore_base
1313

14-
logger = logging.getLogger('concore')
14+
logger = logging.getLogger("concore")
1515
logger.addHandler(logging.NullHandler())
1616

17-
#these lines mute the noisy library
18-
logging.getLogger('matplotlib').setLevel(logging.WARNING)
19-
logging.getLogger('PIL').setLevel(logging.WARNING)
20-
logging.getLogger('urllib3').setLevel(logging.WARNING)
21-
logging.getLogger('requests').setLevel(logging.WARNING)
17+
# these lines mute the noisy library
18+
logging.getLogger("matplotlib").setLevel(logging.WARNING)
19+
logging.getLogger("PIL").setLevel(logging.WARNING)
20+
logging.getLogger("urllib3").setLevel(logging.WARNING)
21+
logging.getLogger("requests").setLevel(logging.WARNING)
2222

2323

2424
# ===================================================================
@@ -51,28 +51,40 @@ def _register_pid():
5151

5252

5353
def _cleanup_pid():
54-
"""Remove the current process PID from the registry on exit."""
54+
"""Remove the current process PID from the registry on exit.
55+
56+
Uses file locking on Windows (msvcrt.locking) to prevent race
57+
conditions when multiple nodes exit concurrently.
58+
"""
5559
pid = str(os.getpid())
5660
try:
5761
if not os.path.exists(_PID_REGISTRY_FILE):
5862
return
59-
with open(_PID_REGISTRY_FILE, "r") as f:
63+
with open(_PID_REGISTRY_FILE, "r+") as f:
64+
# Acquire an exclusive lock on Windows to prevent concurrent
65+
# read-modify-write races between exiting nodes.
66+
if hasattr(sys, "getwindowsversion"):
67+
import msvcrt
68+
69+
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
6070
pids = [line.strip() for line in f if line.strip()]
61-
remaining = [p for p in pids if p != pid]
62-
if remaining:
63-
with open(_PID_REGISTRY_FILE, "w") as f:
71+
remaining = [p for p in pids if p != pid]
72+
if remaining:
73+
f.seek(0)
74+
f.truncate()
6475
for p in remaining:
6576
f.write(p + "\n")
66-
else:
67-
# No PIDs left — clean up both files
68-
try:
69-
os.remove(_PID_REGISTRY_FILE)
70-
except OSError:
71-
pass
72-
try:
73-
os.remove(_KILL_SCRIPT_FILE)
74-
except OSError:
75-
pass
77+
else:
78+
f.close()
79+
# No PIDs left — clean up both files
80+
try:
81+
os.remove(_PID_REGISTRY_FILE)
82+
except OSError:
83+
pass
84+
try:
85+
os.remove(_KILL_SCRIPT_FILE)
86+
except OSError:
87+
pass
7688
except OSError:
7789
pass # Non-fatal: best-effort cleanup
7890

@@ -88,28 +100,34 @@ def _write_kill_script():
88100
"""
89101
try:
90102
script = "@echo off\r\n"
91-
script += "if not exist \"%~dp0" + _PID_REGISTRY_FILE + "\" (\r\n"
103+
script += 'if not exist "%~dp0' + _PID_REGISTRY_FILE + '" (\r\n'
92104
script += " echo No PID registry found. Nothing to kill.\r\n"
93105
script += " exit /b 0\r\n"
94106
script += ")\r\n"
95-
script += "for /f \"tokens=*\" %%p in (%~dp0" + _PID_REGISTRY_FILE + ") do (\r\n"
96-
script += " tasklist /FI \"PID eq %%p\" 2>nul | find /i \"python\" >nul\r\n"
107+
script += (
108+
'for /f "usebackq tokens=*" %%p in ("%~dp0'
109+
+ _PID_REGISTRY_FILE
110+
+ '") do (\r\n'
111+
)
112+
script += ' tasklist /FI "PID eq %%p" 2>nul | find /i "python" >nul\r\n'
97113
script += " if not errorlevel 1 (\r\n"
98114
script += " echo Killing Python process %%p\r\n"
99115
script += " taskkill /F /PID %%p >nul 2>&1\r\n"
100116
script += " ) else (\r\n"
101-
script += " echo Skipping PID %%p - not a Python process or not running\r\n"
117+
script += (
118+
" echo Skipping PID %%p - not a Python process or not running\r\n"
119+
)
102120
script += " )\r\n"
103121
script += ")\r\n"
104-
script += "del /q \"%~dp0" + _PID_REGISTRY_FILE + "\" 2>nul\r\n"
105-
script += "del /q \"%~dp0" + _KILL_SCRIPT_FILE + "\" 2>nul\r\n"
106-
with open(_KILL_SCRIPT_FILE, "w") as f:
122+
script += 'del /q "%~dp0' + _PID_REGISTRY_FILE + '" 2>nul\r\n'
123+
script += 'del /q "%~dp0' + _KILL_SCRIPT_FILE + '" 2>nul\r\n'
124+
with open(_KILL_SCRIPT_FILE, "w", newline="") as f:
107125
f.write(script)
108126
except OSError:
109127
pass # Non-fatal: best-effort script generation
110128

111129

112-
if hasattr(sys, 'getwindowsversion'):
130+
if hasattr(sys, "getwindowsversion"):
113131
_register_pid()
114132
_write_kill_script()
115133
atexit.register(_cleanup_pid)
@@ -125,17 +143,19 @@ def _write_kill_script():
125143

126144
last_read_status = "SUCCESS"
127145

128-
s = ''
129-
olds = ''
146+
s = ""
147+
olds = ""
130148
delay = 1
131149
retrycount = 0
132-
inpath = "./in" #must be rel path for local
150+
inpath = "./in" # must be rel path for local
133151
outpath = "./out"
134152
simtime = 0
135153

154+
136155
def _port_path(base, port_num):
137156
return base + str(port_num)
138157

158+
139159
concore_params_file = os.path.join(_port_path(inpath, 1), "concore.params")
140160
concore_maxtime_file = os.path.join(_port_path(inpath, 1), "concore.maxtime")
141161

@@ -145,16 +165,19 @@ def _port_path(base, port_num):
145165

146166
_mod = sys.modules[__name__]
147167

168+
148169
# ===================================================================
149170
# ZeroMQ Communication Wrapper
150171
# ===================================================================
151172
def init_zmq_port(port_name, port_type, address, socket_type_str):
152173
concore_base.init_zmq_port(_mod, port_name, port_type, address, socket_type_str)
153174

175+
154176
def terminate_zmq():
155177
"""Clean up all ZMQ sockets and contexts before exit."""
156178
concore_base.terminate_zmq(_mod)
157179

180+
158181
def signal_handler(sig, frame):
159182
"""Handle interrupt signals gracefully."""
160183
print(f"\nReceived signal {sig}, shutting down gracefully...")
@@ -165,20 +188,23 @@ def signal_handler(sig, frame):
165188
concore_base.terminate_zmq(_mod)
166189
sys.exit(0)
167190

191+
168192
# Register cleanup handlers
169193
atexit.register(terminate_zmq)
170-
signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C
171-
if not hasattr(sys, 'getwindowsversion'):
194+
signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C
195+
if not hasattr(sys, "getwindowsversion"):
172196
signal.signal(signal.SIGTERM, signal_handler) # Handle termination (Unix only)
173197

174198
params = concore_base.load_params(concore_params_file)
175199

176-
#9/30/22
200+
201+
# 9/30/22
177202
def tryparam(n, i):
178203
"""Return parameter `n` from params dict, else default `i`."""
179204
return params.get(n, i)
180205

181-
#9/12/21
206+
207+
# 9/12/21
182208
# ===================================================================
183209
# Simulation Time Handling
184210
# ===================================================================
@@ -187,12 +213,15 @@ def default_maxtime(default):
187213
global maxtime
188214
maxtime = safe_literal_eval(concore_maxtime_file, default)
189215

216+
190217
default_maxtime(100)
191218

219+
192220
def unchanged():
193221
"""Check if global string `s` is unchanged since last call."""
194222
return concore_base.unchanged(_mod)
195223

224+
196225
# ===================================================================
197226
# I/O Handling (File + ZMQ)
198227
# ===================================================================
@@ -228,5 +257,6 @@ def read(port_identifier, name, initstr_val):
228257
def write(port_identifier, name, val, delta=0):
229258
concore_base.write(_mod, port_identifier, name, val, delta)
230259

231-
def initval(simtime_val_str):
260+
261+
def initval(simtime_val_str):
232262
return concore_base.initval(_mod, simtime_val_str)

tests/test_concore.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# PID Registry Tests (Issue #391)
99
# ===================================================================
1010

11+
1112
class TestPidRegistry:
1213
"""Tests for the Windows PID registry mechanism that replaces the
1314
old single-overwrite concorekill.bat approach."""
@@ -21,6 +22,7 @@ def use_temp_dir(self, temp_dir, monkeypatch):
2122
def test_register_pid_creates_registry_file(self):
2223
"""_register_pid should create concorekill_pids.txt with current PID."""
2324
from concore import _register_pid, _PID_REGISTRY_FILE
25+
2426
_register_pid()
2527
assert os.path.exists(_PID_REGISTRY_FILE)
2628
with open(_PID_REGISTRY_FILE) as f:
@@ -30,6 +32,7 @@ def test_register_pid_creates_registry_file(self):
3032
def test_register_pid_appends_not_overwrites(self):
3133
"""Multiple calls to _register_pid should append, not overwrite."""
3234
from concore import _register_pid, _PID_REGISTRY_FILE
35+
3336
# Simulate two different PIDs by writing manually then registering
3437
with open(_PID_REGISTRY_FILE, "w") as f:
3538
f.write("11111\n")
@@ -45,6 +48,7 @@ def test_register_pid_appends_not_overwrites(self):
4548
def test_cleanup_pid_removes_current_pid(self):
4649
"""_cleanup_pid should remove only the current PID from the registry."""
4750
from concore import _cleanup_pid, _PID_REGISTRY_FILE
51+
4852
current_pid = str(os.getpid())
4953
with open(_PID_REGISTRY_FILE, "w") as f:
5054
f.write("99999\n")
@@ -61,6 +65,7 @@ def test_cleanup_pid_deletes_files_when_last_pid(self):
6165
"""When the current PID is the only one left, cleanup should
6266
remove both the registry file and the kill script."""
6367
from concore import _cleanup_pid, _PID_REGISTRY_FILE, _KILL_SCRIPT_FILE
68+
6469
current_pid = str(os.getpid())
6570
with open(_PID_REGISTRY_FILE, "w") as f:
6671
f.write(current_pid + "\n")
@@ -74,12 +79,14 @@ def test_cleanup_pid_deletes_files_when_last_pid(self):
7479
def test_cleanup_pid_handles_missing_registry(self):
7580
"""_cleanup_pid should not crash when registry file doesn't exist."""
7681
from concore import _cleanup_pid, _PID_REGISTRY_FILE
82+
7783
assert not os.path.exists(_PID_REGISTRY_FILE)
7884
_cleanup_pid() # Should not raise
7985

8086
def test_write_kill_script_generates_bat_file(self):
8187
"""_write_kill_script should create concorekill.bat with validation logic."""
8288
from concore import _write_kill_script, _KILL_SCRIPT_FILE, _PID_REGISTRY_FILE
89+
8390
_write_kill_script()
8491
assert os.path.exists(_KILL_SCRIPT_FILE)
8592
with open(_KILL_SCRIPT_FILE) as f:
@@ -94,6 +101,7 @@ def test_write_kill_script_generates_bat_file(self):
94101
def test_multi_node_registration(self):
95102
"""Simulate 3 nodes registering PIDs — all should be present."""
96103
from concore import _register_pid, _PID_REGISTRY_FILE
104+
97105
fake_pids = ["1204", "1932", "8120"]
98106
with open(_PID_REGISTRY_FILE, "w") as f:
99107
for pid in fake_pids:
@@ -109,6 +117,7 @@ def test_multi_node_registration(self):
109117
def test_cleanup_preserves_other_pids(self):
110118
"""After cleanup, only the current process PID should be removed."""
111119
from concore import _cleanup_pid, _PID_REGISTRY_FILE
120+
112121
current_pid = str(os.getpid())
113122
other_pids = ["1111", "2222", "3333"]
114123
with open(_PID_REGISTRY_FILE, "w") as f:
@@ -123,17 +132,30 @@ def test_cleanup_preserves_other_pids(self):
123132
for pid in other_pids:
124133
assert pid in pids
125134

126-
@pytest.mark.skipif(not hasattr(sys, 'getwindowsversion'),
127-
reason="Windows-only test")
135+
@pytest.mark.skipif(
136+
not hasattr(sys, "getwindowsversion"), reason="Windows-only test"
137+
)
128138
def test_import_registers_pid_on_windows(self):
129-
"""On Windows, importing concore should register the PID."""
130-
from concore import _PID_REGISTRY_FILE
131-
# The import already happened, so just verify the registry exists
132-
# in our temp dir (we can't easily test the import-time side effect
133-
# since concore was already imported — we test the functions directly)
134-
from concore import _register_pid
135-
_register_pid()
136-
assert os.path.exists(_PID_REGISTRY_FILE)
139+
"""On Windows, importing concore should register the PID.
140+
141+
We force a fresh import by removing the cached module so that
142+
the module-level registration code runs inside our temp dir.
143+
"""
144+
import importlib
145+
146+
# Remove cached modules so re-import triggers module-level code
147+
for mod_name in ("concore", "concore_base"):
148+
sys.modules.pop(mod_name, None)
149+
150+
import concore # noqa: F811 – intentional reimport
151+
152+
assert os.path.exists(concore._PID_REGISTRY_FILE)
153+
with open(concore._PID_REGISTRY_FILE) as f:
154+
pids = [line.strip() for line in f if line.strip()]
155+
assert str(os.getpid()) in pids
156+
157+
# Restore module for other tests
158+
importlib.reload(concore)
137159

138160

139161
class TestSafeLiteralEval:

0 commit comments

Comments
 (0)