"""Cross-platform locked file for the database (no separate .lock files). Unix: open() + fcntl.flock (advisory, cooperative among processes that flock). Windows: CreateFileW with FILE_SHARE_READ (OS-enforced, allows readers, blocks writers). A single file descriptor is opened once for both reading and writing. The lock is acquired atomically (on Windows) or immediately after open (on Unix), and the same descriptor is used for the lifetime of the process: first to read the existing content, then to append new writes. """ import logging import os import sys from pathlib import Path from kanta.exceptions import FileLockError _logger = logging.getLogger(__name__) def _fatal(msg: str, *, db_path: Path | None = None) -> None: """Log a fatal error and raise a typed exception.""" _logger.critical(msg) raise FileLockError(msg, db_path=db_path) if sys.platform == "win32": import ctypes from ctypes import wintypes _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) _GENERIC_READ = 0x80000000 _GENERIC_WRITE = 0x40000000 _FILE_SHARE_READ = 0x00000001 _FILE_SHARE_WRITE = 0x00000002 _OPEN_EXISTING = 3 _OPEN_ALWAYS = 4 _FILE_ATTRIBUTE_NORMAL = 0x80 _FILE_BEGIN = 0 _FILE_END = 2 _ERROR_SHARING_VIOLATION = 32 _INVALID_FILE_SIZE = 0xFFFFFFFF _kernel32.CreateFileW.restype = wintypes.HANDLE _kernel32.CreateFileW.argtypes = [ wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE, ] _kernel32.ReadFile.restype = wintypes.BOOL _kernel32.ReadFile.argtypes = [ wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p, ] _kernel32.WriteFile.restype = wintypes.BOOL _kernel32.WriteFile.argtypes = [ wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p, ] _kernel32.GetFileSize.restype = wintypes.DWORD _kernel32.GetFileSize.argtypes = [ wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD), ] _kernel32.SetFilePointer.restype = wintypes.DWORD _kernel32.SetFilePointer.argtypes = [ wintypes.HANDLE, wintypes.LONG, ctypes.POINTER(wintypes.LONG), wintypes.DWORD, ] _kernel32.CloseHandle.restype = wintypes.BOOL _kernel32.CloseHandle.argtypes = [wintypes.HANDLE] def _is_invalid_handle(handle) -> bool: return ctypes.c_void_p(handle).value == ctypes.c_void_p(-1).value else: import fcntl class LockedFile: """A file opened for read+write with an optional exclusive lock. Usage:: f = LockedFile() f.open(path) # open + lock (read+write) f.open(path, readonly=True) # open read-only without locking content = f.read() # read entire content f.write(data) # append data (seeks to end first) f.close() # release lock + close fd Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected. Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers. """ def __init__(self) -> None: self._fd: int | None = None # Unix fd or Windows HANDLE def open(self, path: Path, *, create: bool = False, readonly: bool = False) -> None: """Open *path* and optionally acquire an exclusive lock. Args: path: File to open and lock. create: If True, create the file if it doesn't exist (bootstrap). readonly: If True, open read-only without acquiring a lock. Raises: FileLockError: If the file is locked by another process or not found. """ if self._fd is not None: return # Already open (idempotent) if sys.platform == "win32": self._open_win32(path, create, readonly) else: self._open_unix(path, create, readonly) def open_and_read( self, path: Path, create: bool = False, readonly: bool = False ) -> bytes: """Open *path* and read all content. Combined operation for efficient use with asyncio.to_thread(). """ self.open(path, create=create, readonly=readonly) return self.read() def read(self) -> bytes: """Read the entire file content from the beginning.""" if self._fd is None: raise RuntimeError("LockedFile.read() called on a closed file") if sys.platform == "win32": return self._read_win32() else: return self._read_unix() def write(self, data: bytes) -> None: """Append *data* to the end of the file.""" if self._fd is None: raise RuntimeError("LockedFile.write() called on a closed file") if sys.platform == "win32": self._write_win32(data) else: self._write_unix(data) def size(self) -> int: """Return current file size in bytes.""" if self._fd is None: raise RuntimeError("LockedFile.size() called on a closed file") if sys.platform == "win32": size = _kernel32.GetFileSize(self._fd, None) if size == _INVALID_FILE_SIZE: raise OSError( f"GetFileSize failed: Windows error {ctypes.get_last_error()}" ) return int(size) current = os.lseek(self._fd, 0, os.SEEK_CUR) end = os.lseek(self._fd, 0, os.SEEK_END) os.lseek(self._fd, current, os.SEEK_SET) return end def close(self) -> None: """Release the lock and close the file.""" if self._fd is None: return if sys.platform == "win32": _kernel32.CloseHandle(self._fd) else: os.close(self._fd) self._fd = None @property def is_open(self) -> bool: return self._fd is not None # -- Unix ---------------------------------------------------------------- def _open_unix(self, path: Path, create: bool, readonly: bool) -> None: if readonly: flags = os.O_RDONLY else: flags = os.O_RDWR | (os.O_CREAT if create else 0) try: fd = os.open(path, flags, 0o666) except FileNotFoundError: _fatal(f"Database file not found: {path.resolve()}", db_path=path) if not readonly: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: os.close(fd) _fatal( f"{path.resolve()}: database already locked by another instance", db_path=path, ) self._fd = fd def _read_unix(self) -> bytes: os.lseek(self._fd, 0, os.SEEK_SET) chunks = [] while True: chunk = os.read(self._fd, 1 << 20) # 1 MiB if not chunk: break chunks.append(chunk) return b"".join(chunks) def _write_unix(self, data: bytes) -> None: os.lseek(self._fd, 0, os.SEEK_END) os.write(self._fd, data) # -- Windows ------------------------------------------------------------- def _open_win32(self, path: Path, create: bool, readonly: bool) -> None: if readonly: disposition = _OPEN_EXISTING access = _GENERIC_READ share = _FILE_SHARE_READ | _FILE_SHARE_WRITE else: disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING access = _GENERIC_READ | _GENERIC_WRITE share = _FILE_SHARE_READ handle = _kernel32.CreateFileW( str(path), access, share, None, disposition, _FILE_ATTRIBUTE_NORMAL, None, ) if _is_invalid_handle(handle): err = ctypes.get_last_error() if err == _ERROR_SHARING_VIOLATION: _fatal( f"{path.resolve()}: database already locked by another instance", db_path=path, ) _fatal( f"Failed to open database {path.resolve()}: Windows error {err}", db_path=path, ) self._fd = handle def _read_win32(self) -> bytes: _kernel32.SetFilePointer(self._fd, 0, None, _FILE_BEGIN) size = _kernel32.GetFileSize(self._fd, None) if size == _INVALID_FILE_SIZE: raise OSError( f"GetFileSize failed: Windows error {ctypes.get_last_error()}" ) if size == 0: return b"" buf = ctypes.create_string_buffer(size) bytes_read = wintypes.DWORD() ok = _kernel32.ReadFile(self._fd, buf, size, ctypes.byref(bytes_read), None) if not ok: raise OSError(f"ReadFile failed: Windows error {ctypes.get_last_error()}") return buf.raw[: bytes_read.value] def _write_win32(self, data: bytes) -> None: _kernel32.SetFilePointer(self._fd, 0, None, _FILE_END) written = wintypes.DWORD() ok = _kernel32.WriteFile( self._fd, data, len(data), ctypes.byref(written), None, ) if not ok: raise OSError(f"WriteFile failed: Windows error {ctypes.get_last_error()}")