Skip to content

Commit 32ec74d

Browse files
authored
Merge pull request #1023 from jphein/pr/pid-file-guard
fix(hooks): PID file guard prevents stacking mine processes
2 parents caf503f + dfba247 commit 32ec74d

2 files changed

Lines changed: 116 additions & 16 deletions

File tree

mempalace/hooks_cli.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,20 +150,70 @@ def _get_mine_dir(transcript_path: str = "") -> str:
150150
return ""
151151

152152

153+
_MINE_PID_FILE = STATE_DIR / "mine.pid"
154+
155+
156+
def _pid_alive(pid: int) -> bool:
157+
"""Cross-platform existence check for a PID.
158+
159+
On POSIX, ``os.kill(pid, 0)`` is the well-known no-op existence probe.
160+
On Windows, ``os.kill`` maps to ``TerminateProcess(handle, sig)`` and
161+
would *terminate* the target process with exit code ``sig`` — using
162+
it here would kill our own mine child (or worse, the caller itself).
163+
Use ``OpenProcess`` + ``GetExitCodeProcess`` via ctypes instead.
164+
"""
165+
if sys.platform == "win32":
166+
import ctypes
167+
from ctypes import wintypes
168+
169+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
170+
STILL_ACTIVE = 259
171+
kernel32 = ctypes.windll.kernel32
172+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
173+
if not handle:
174+
return False
175+
try:
176+
code = wintypes.DWORD()
177+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
178+
return False
179+
return code.value == STILL_ACTIVE
180+
finally:
181+
kernel32.CloseHandle(handle)
182+
try:
183+
os.kill(pid, 0)
184+
return True
185+
except (OSError, ValueError):
186+
return False
187+
188+
189+
def _mine_already_running() -> bool:
190+
"""Return True if a background mine process from a previous hook fire is still alive."""
191+
try:
192+
pid = int(_MINE_PID_FILE.read_text().strip())
193+
except (OSError, ValueError):
194+
return False
195+
return _pid_alive(pid)
196+
197+
198+
def _spawn_mine(cmd: list) -> None:
199+
"""Spawn a mine subprocess, write its PID to the lock file, log to hook.log."""
200+
STATE_DIR.mkdir(parents=True, exist_ok=True)
201+
log_path = STATE_DIR / "hook.log"
202+
with open(log_path, "a") as log_f:
203+
proc = subprocess.Popen(cmd, stdout=log_f, stderr=log_f)
204+
_MINE_PID_FILE.write_text(str(proc.pid))
205+
206+
153207
def _maybe_auto_ingest(transcript_path: str = ""):
154208
"""Run mempalace mine in background if a mine directory is available."""
155209
mine_dir = _get_mine_dir(transcript_path)
156210
if not mine_dir:
157211
return
212+
if _mine_already_running():
213+
_log("Skipping auto-ingest: mine already running")
214+
return
158215
try:
159-
STATE_DIR.mkdir(parents=True, exist_ok=True)
160-
log_path = STATE_DIR / "hook.log"
161-
with open(log_path, "a") as log_f:
162-
subprocess.Popen(
163-
[sys.executable, "-m", "mempalace", "mine", mine_dir],
164-
stdout=log_f,
165-
stderr=log_f,
166-
)
216+
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
167217
except OSError:
168218
pass
169219

tests/test_hooks_cli.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import io
33
import json
4+
import os
45
import subprocess
56
from pathlib import Path
67
from unittest.mock import patch
@@ -14,6 +15,7 @@
1415
_get_mine_dir,
1516
_log,
1617
_maybe_auto_ingest,
18+
_mine_already_running,
1719
_parse_harness_input,
1820
_sanitize_session_id,
1921
_validate_transcript_path,
@@ -250,9 +252,10 @@ def test_maybe_auto_ingest_with_env(tmp_path):
250252
mempal_dir.mkdir()
251253
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
252254
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
253-
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
254-
_maybe_auto_ingest()
255-
mock_popen.assert_called_once()
255+
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
256+
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
257+
_maybe_auto_ingest()
258+
mock_popen.assert_called_once()
256259

257260

258261
def test_maybe_auto_ingest_with_transcript(tmp_path):
@@ -261,9 +264,10 @@ def test_maybe_auto_ingest_with_transcript(tmp_path):
261264
transcript.write_text("")
262265
with patch.dict("os.environ", {}, clear=True):
263266
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
264-
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
265-
_maybe_auto_ingest(str(transcript))
266-
mock_popen.assert_called_once()
267+
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
268+
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
269+
_maybe_auto_ingest(str(transcript))
270+
mock_popen.assert_called_once()
267271

268272

269273
def test_maybe_auto_ingest_oserror(tmp_path):
@@ -272,8 +276,54 @@ def test_maybe_auto_ingest_oserror(tmp_path):
272276
mempal_dir.mkdir()
273277
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
274278
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
275-
with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("fail")):
276-
_maybe_auto_ingest() # should not raise
279+
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
280+
with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("fail")):
281+
_maybe_auto_ingest() # should not raise
282+
283+
284+
def test_maybe_auto_ingest_skips_when_mine_running(tmp_path):
285+
"""Does not spawn a new mine process if one is already running."""
286+
mempal_dir = tmp_path / "project"
287+
mempal_dir.mkdir()
288+
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
289+
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
290+
with patch("mempalace.hooks_cli._mine_already_running", return_value=True):
291+
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
292+
_maybe_auto_ingest()
293+
mock_popen.assert_not_called()
294+
295+
296+
# --- _mine_already_running ---
297+
298+
299+
def test_mine_already_running_no_file(tmp_path):
300+
"""Returns False when no PID file exists."""
301+
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
302+
assert _mine_already_running() is False
303+
304+
305+
def test_mine_already_running_dead_pid(tmp_path):
306+
"""Returns False when PID file contains a PID that no longer exists."""
307+
pid_file = tmp_path / "mine.pid"
308+
pid_file.write_text("999999999") # almost certainly not a real PID
309+
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
310+
assert _mine_already_running() is False
311+
312+
313+
def test_mine_already_running_live_pid(tmp_path):
314+
"""Returns True when PID file contains the current process's own PID."""
315+
pid_file = tmp_path / "mine.pid"
316+
pid_file.write_text(str(os.getpid())) # current process is definitely alive
317+
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
318+
assert _mine_already_running() is True
319+
320+
321+
def test_mine_already_running_corrupt_file(tmp_path):
322+
"""Returns False when PID file contains non-integer content."""
323+
pid_file = tmp_path / "mine.pid"
324+
pid_file.write_text("not-a-pid")
325+
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
326+
assert _mine_already_running() is False
277327

278328

279329
# --- _get_mine_dir ---

0 commit comments

Comments
 (0)