Refuse to run a second eris instance on the same directory

flock on .eris/lock prevents two eris processes racing the cache, which
previously produced a mixed-format summary (int-list pages) and crashed
sync_with_filesystem. The lock is acquired before manage_cache, the lock
file survives checksum-mismatch cleanup, and the holder's PID is written
to the lock file so a refused instance can report it.
This commit is contained in:
Andrew Hamilton 2026-08-04 09:25:49 +10:00
parent 980ee7071c
commit 329f500c8e
2 changed files with 115 additions and 13 deletions

View file

@ -14,6 +14,7 @@ directory.
import asyncio
import contextlib
import fcntl
import functools
import gzip
import hashlib
@ -1106,23 +1107,58 @@ def source_checksum():
return sha256.hexdigest()
class LockError(Exception):
def __init__(self, holder_pid):
super().__init__(holder_pid)
self.holder_pid = holder_pid
def acquire_lock(cache_path):
"""Take an exclusive flock on the cache lock file, refusing if another
eris instance is running on the same directory."""
os.makedirs(cache_path, exist_ok=True)
lock_path = os.path.join(cache_path, "lock")
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(lock_fd)
holder_pid = None
try:
with open(lock_path, "r") as lock_file:
holder_pid = lock_file.read().strip() or None
except OSError:
pass
raise LockError(holder_pid) from None
os.ftruncate(lock_fd, 0)
os.write(lock_fd, str(os.getpid()).encode())
return lock_fd
def manage_cache(root_path):
cache_path = os.path.join(root_path, tools.CACHE_PATH)
checksum_path = os.path.join(cache_path, "source_checksum")
if os.path.exists(cache_path):
try:
with open(checksum_path, "r") as checksum_file:
cache_checksum = checksum_file.read()
except FileNotFoundError:
cache_checksum = None
if source_checksum() != cache_checksum:
cache_checksum = None
if os.path.exists(checksum_path):
with open(checksum_path, "r") as checksum_file:
cache_checksum = checksum_file.read()
if source_checksum() != cache_checksum:
contents = ([name for name in os.listdir(cache_path) if name != "lock"]
if os.path.isdir(cache_path) else [])
if contents:
print("Eris has changed, recalculating all results…")
shutil.rmtree(cache_path)
if not os.path.exists(cache_path):
os.mkdir(cache_path)
with open(checksum_path, "w") as checksum_file:
checksum_file.write(source_checksum())
gitignore_path = os.path.join(cache_path, ".gitignore")
for name in contents:
path = os.path.join(cache_path, name)
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path)
else:
os.unlink(path)
os.makedirs(cache_path, exist_ok=True)
with open(checksum_path, "w") as checksum_file:
checksum_file.write(source_checksum())
gitignore_path = os.path.join(cache_path, ".gitignore")
if not os.path.exists(gitignore_path):
with open(gitignore_path, "w") as gitignore_file:
gitignore_file.write("*")
@ -1268,6 +1304,17 @@ def inotify_watches_exceeded():
def entry_point():
(root_path, worker_count, editor_command, theme, compression,
use_window) = check_arguments()
try:
lock_fd = acquire_lock(os.path.join(root_path, tools.CACHE_PATH))
except LockError as lock_error:
if lock_error.holder_pid:
print(f"Error: another eris is already running on this directory "
f"(pid {lock_error.holder_pid}).")
else:
print("Error: another eris is already running on this directory.")
print("Eris only supports one instance per project directory.")
print("If this is a stale process, kill it first and try again.")
sys.exit(1)
manage_cache(root_path)
title = "eris: " + os.path.basename(root_path)
with chdir(root_path): # FIX: Don't change directory if possible.

View file

@ -201,5 +201,60 @@ class SummarySyncWithFilesystemTestCase(unittest.TestCase):
id(self.summary._entries[3])) # zoo
self.assertTrue(self.jobs_added_event.is_set())
class CacheLockTestCase(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.cache_path = os.path.join(self.temp_dir, tools.CACHE_PATH)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_acquire_lock_conflict_and_release(self):
lock_fd = __main__.acquire_lock(self.cache_path)
lock_path = os.path.join(self.cache_path, "lock")
self.assertTrue(os.path.exists(lock_path))
with open(lock_path) as lock_file:
self.assertEqual(lock_file.read(), str(os.getpid()))
with self.assertRaises(__main__.LockError) as error:
__main__.acquire_lock(self.cache_path)
self.assertEqual(error.exception.holder_pid, str(os.getpid()))
os.close(lock_fd)
lock_fd = __main__.acquire_lock(self.cache_path)
os.close(lock_fd)
def test_manage_cache_preserves_lock_on_checksum_mismatch(self):
lock_fd = __main__.acquire_lock(self.cache_path)
lock_path = os.path.join(self.cache_path, "lock")
with open(os.path.join(self.cache_path, "source_checksum"), "w") as checksum_file:
checksum_file.write("stale-checksum")
stale_pickle = os.path.join(self.cache_path, "summary.pickle")
stale_dir = os.path.join(self.cache_path, "summary_dir")
_touch(stale_pickle)
os.mkdir(stale_dir)
original_checksum = __main__.source_checksum
__main__.source_checksum = lambda: "new-checksum"
try:
__main__.manage_cache(self.temp_dir)
finally:
__main__.source_checksum = original_checksum
os.close(lock_fd)
self.assertTrue(os.path.exists(lock_path))
self.assertFalse(os.path.exists(stale_pickle))
self.assertFalse(os.path.exists(stale_dir))
with open(os.path.join(self.cache_path, "source_checksum")) as checksum_file:
self.assertEqual(checksum_file.read(), "new-checksum")
self.assertTrue(os.path.exists(os.path.join(self.cache_path, ".gitignore")))
def test_manage_cache_first_run_keeps_lock(self):
lock_fd = __main__.acquire_lock(self.cache_path)
os.close(lock_fd)
__main__.manage_cache(self.temp_dir)
self.assertTrue(os.path.exists(os.path.join(self.cache_path, "lock")))
with open(os.path.join(self.cache_path, "source_checksum")) as checksum_file:
self.assertEqual(checksum_file.read(), __main__.source_checksum())
self.assertTrue(os.path.exists(os.path.join(self.cache_path, ".gitignore")))
if __name__ == "__main__":
golden.main()