diff --git a/VERSION b/VERSION index a20e3d5..ea2dee2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.25 +1.8.26 diff --git a/src/torchlight/Commands.py b/src/torchlight/Commands.py index 6c23683..3e288fd 100644 --- a/src/torchlight/Commands.py +++ b/src/torchlight/Commands.py @@ -20,6 +20,7 @@ from torchlight.AccessManager import AccessManager from torchlight.AudioManager import AudioManager from torchlight.Config import Config +from torchlight.GeoIP import get_city_reader from torchlight.MyInstants import myinstants_get_random_sound from torchlight.Player import Player from torchlight.PlayerManager import PlayerManager @@ -422,12 +423,15 @@ def __init__( ) self.config_folder = self.torchlight.config["GeoIP"]["Path"] self.city_filename = self.torchlight.config["GeoIP"]["CityFilename"] - self.geo_ip = geoip2.database.Reader(f"{self.config_folder}/{self.city_filename}") - - def close(self) -> None: - # CommandHandler.Setup() rebuilds every command on each reload; without this the - # previous Reader's mmap of the GeoIP database is only released on GC (issue #55). - self.geo_ip.close() + self.geo_ip: geoip2.database.Reader | None = None + database_path = f"{self.config_folder}/{self.city_filename}" + try: + self.geo_ip = get_city_reader(database_path) + except Exception: + self.logger.error( + f"Failed to open GeoIP database ({database_path}); " + f"!ow without an explicit location will be unavailable\n{traceback.format_exc()}" + ) def degreeToCardinal(self, degree: int) -> str: directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -444,6 +448,9 @@ async def _func(self, message: list[str], player: Player) -> int: if not message[1]: # Use GeoIP location + if self.geo_ip is None: + self.torchlight.SayPrivate(player, "[OW] Location lookup is unavailable, please specify a city.") + return 1 info = self.geo_ip.city(player.address.split(":")[0]) search = f"lat={info.location.latitude}&lon={info.location.longitude}" else: diff --git a/src/torchlight/GeoIP.py b/src/torchlight/GeoIP.py new file mode 100644 index 0000000..e5a6353 --- /dev/null +++ b/src/torchlight/GeoIP.py @@ -0,0 +1,58 @@ +import logging +import os + +import geoip2.database + +logger = logging.getLogger(__name__) + +# Cached per database path as (mtime, reader). Kept at module level - and in this +# module rather than in Commands.py - so it survives importlib.reload(Commands) +# when the bot processes a !reload. +_readers: dict[str, tuple[float, geoip2.database.Reader]] = {} + + +def _close_quietly(reader: geoip2.database.Reader) -> None: + try: + reader.close() + except Exception: + logger.debug("Failed to close previous GeoIP reader", exc_info=True) + + +def get_city_reader(database_path: str) -> geoip2.database.Reader: + """Open the GeoIP city database once and reuse it across reloads. + + ``CommandHandler.Setup`` rebuilds every command whenever the configuration is + reloaded. Opening a fresh :class:`geoip2.database.Reader` each time leaked the + previous memory map of the database until garbage collection (issue #55) and + could then fail to map the file at all, breaking command setup (issue #174). + + The database is read-only, so a single reader is kept per path and only + reopened when the file on disk changes (for instance after a GeoIP update). + """ + cached = _readers.get(database_path) + + try: + mtime = os.path.getmtime(database_path) + except OSError: + if cached is not None: + return cached[1] + raise + + if cached is not None and cached[0] == mtime: + return cached[1] + + try: + reader = geoip2.database.Reader(database_path) + except Exception: + if cached is not None: + # A refreshed database file is momentarily unreadable; keep serving + # the reader we already have instead of losing GeoIP entirely. + logger.warning(f"Could not reopen GeoIP database {database_path}, keeping the previous reader") + return cached[1] + raise + + if cached is not None: + _close_quietly(cached[1]) + _readers[database_path] = (mtime, reader) + logger.info(f"Opened GeoIP database {database_path}") + return reader