Merge pull request #1023 from jphein/pr/pid-file-guard

fix(hooks): PID file guard prevents stacking mine processes
This commit is contained in:
Ben Sigman
2026-04-18 23:33:47 -07:00
committed by GitHub
2 changed files with 116 additions and 16 deletions
+58 -8
View File
@@ -150,20 +150,70 @@ def _get_mine_dir(transcript_path: str = "") -> str:
return "" return ""
_MINE_PID_FILE = STATE_DIR / "mine.pid"
def _pid_alive(pid: int) -> bool:
"""Cross-platform existence check for a PID.
On POSIX, ``os.kill(pid, 0)`` is the well-known no-op existence probe.
On Windows, ``os.kill`` maps to ``TerminateProcess(handle, sig)`` and
would *terminate* the target process with exit code ``sig`` — using
it here would kill our own mine child (or worse, the caller itself).
Use ``OpenProcess`` + ``GetExitCodeProcess`` via ctypes instead.
"""
if sys.platform == "win32":
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return False
return code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
return True
except (OSError, ValueError):
return False
def _mine_already_running() -> bool:
"""Return True if a background mine process from a previous hook fire is still alive."""
try:
pid = int(_MINE_PID_FILE.read_text().strip())
except (OSError, ValueError):
return False
return _pid_alive(pid)
def _spawn_mine(cmd: list) -> None:
"""Spawn a mine subprocess, write its PID to the lock file, log to hook.log."""
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
proc = subprocess.Popen(cmd, stdout=log_f, stderr=log_f)
_MINE_PID_FILE.write_text(str(proc.pid))
def _maybe_auto_ingest(transcript_path: str = ""): def _maybe_auto_ingest(transcript_path: str = ""):
"""Run mempalace mine in background if a mine directory is available.""" """Run mempalace mine in background if a mine directory is available."""
mine_dir = _get_mine_dir(transcript_path) mine_dir = _get_mine_dir(transcript_path)
if not mine_dir: if not mine_dir:
return return
if _mine_already_running():
_log("Skipping auto-ingest: mine already running")
return
try: try:
STATE_DIR.mkdir(parents=True, exist_ok=True) _spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.Popen(
[sys.executable, "-m", "mempalace", "mine", mine_dir],
stdout=log_f,
stderr=log_f,
)
except OSError: except OSError:
pass pass
+58 -8
View File
@@ -1,6 +1,7 @@
import contextlib import contextlib
import io import io
import json import json
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -14,6 +15,7 @@ from mempalace.hooks_cli import (
_get_mine_dir, _get_mine_dir,
_log, _log,
_maybe_auto_ingest, _maybe_auto_ingest,
_mine_already_running,
_parse_harness_input, _parse_harness_input,
_sanitize_session_id, _sanitize_session_id,
_validate_transcript_path, _validate_transcript_path,
@@ -250,9 +252,10 @@ def test_maybe_auto_ingest_with_env(tmp_path):
mempal_dir.mkdir() mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}): with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen: with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
_maybe_auto_ingest() with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
mock_popen.assert_called_once() _maybe_auto_ingest()
mock_popen.assert_called_once()
def test_maybe_auto_ingest_with_transcript(tmp_path): def test_maybe_auto_ingest_with_transcript(tmp_path):
@@ -261,9 +264,10 @@ def test_maybe_auto_ingest_with_transcript(tmp_path):
transcript.write_text("") transcript.write_text("")
with patch.dict("os.environ", {}, clear=True): with patch.dict("os.environ", {}, clear=True):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen: with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
_maybe_auto_ingest(str(transcript)) with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
mock_popen.assert_called_once() _maybe_auto_ingest(str(transcript))
mock_popen.assert_called_once()
def test_maybe_auto_ingest_oserror(tmp_path): def test_maybe_auto_ingest_oserror(tmp_path):
@@ -272,8 +276,54 @@ def test_maybe_auto_ingest_oserror(tmp_path):
mempal_dir.mkdir() mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}): with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("fail")): with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
_maybe_auto_ingest() # should not raise with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("fail")):
_maybe_auto_ingest() # should not raise
def test_maybe_auto_ingest_skips_when_mine_running(tmp_path):
"""Does not spawn a new mine process if one is already running."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._mine_already_running", return_value=True):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
mock_popen.assert_not_called()
# --- _mine_already_running ---
def test_mine_already_running_no_file(tmp_path):
"""Returns False when no PID file exists."""
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
assert _mine_already_running() is False
def test_mine_already_running_dead_pid(tmp_path):
"""Returns False when PID file contains a PID that no longer exists."""
pid_file = tmp_path / "mine.pid"
pid_file.write_text("999999999") # almost certainly not a real PID
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
assert _mine_already_running() is False
def test_mine_already_running_live_pid(tmp_path):
"""Returns True when PID file contains the current process's own PID."""
pid_file = tmp_path / "mine.pid"
pid_file.write_text(str(os.getpid())) # current process is definitely alive
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
assert _mine_already_running() is True
def test_mine_already_running_corrupt_file(tmp_path):
"""Returns False when PID file contains non-integer content."""
pid_file = tmp_path / "mine.pid"
pid_file.write_text("not-a-pid")
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
assert _mine_already_running() is False
# --- _get_mine_dir --- # --- _get_mine_dir ---