1010from __future__ import annotations
1111
1212import argparse
13+ import ctypes
1314import json
1415import logging
1516import os
@@ -1653,6 +1654,68 @@ def kill_pid(name: str) -> bool:
16531654 return True
16541655
16551656
1657+ try :
1658+ _LIBPROC = ctypes .CDLL ("libproc.dylib" , use_errno = True )
1659+ _LIBPROC .proc_pidinfo .restype = ctypes .c_int
1660+ _LIBPROC .proc_pidinfo .argtypes = [
1661+ ctypes .c_int ,
1662+ ctypes .c_int ,
1663+ ctypes .c_uint64 ,
1664+ ctypes .c_void_p ,
1665+ ctypes .c_int ,
1666+ ]
1667+ except OSError :
1668+ _LIBPROC = None
1669+
1670+ _PROC_PIDLISTFDS = 1
1671+ _PROC_FDINFO_SIZE = 8 # sizeof(struct proc_fdinfo): int32 fd + uint32 fdtype
1672+
1673+
1674+ def _process_fd_count (pid : int ) -> int | None :
1675+ """Live open file-descriptor count for a pid via libproc, or None.
1676+
1677+ Used by the watcher's fd-leak safety net (#89). proc_pidinfo(PROC_PIDLISTFDS)
1678+ with a NULL buffer returns the fd-table CAPACITY, a high-water mark that
1679+ never shrinks when fds close, so we must pass a real buffer and count the
1680+ bytes actually written (live_nfds * sizeof(proc_fdinfo)). Two syscalls, no
1681+ subprocess: unlike lsof it can't hang on a stalled mount, does no DNS on the
1682+ worker's DB/Redis sockets, and stays fast even when the table is huge (the
1683+ exact case we must catch).
1684+ """
1685+ if _LIBPROC is None :
1686+ return None
1687+ capacity = _LIBPROC .proc_pidinfo (pid , _PROC_PIDLISTFDS , 0 , None , 0 )
1688+ if capacity <= 0 :
1689+ return None
1690+ buf = ctypes .create_string_buffer (capacity )
1691+ written = _LIBPROC .proc_pidinfo (pid , _PROC_PIDLISTFDS , 0 , buf , capacity )
1692+ if written <= 0 :
1693+ return None
1694+ return written // _PROC_FDINFO_SIZE
1695+
1696+
1697+ def _worker_fd_total () -> int | None :
1698+ """Total open fds across all live Immich worker processes, or None.
1699+
1700+ Immich 2.7+ runs several processes titled 'immich'; the file-handle leak
1701+ (#89) can accumulate in any of them, not just the one tracked in worker.pid,
1702+ so sum every worker process rather than measuring a single pid.
1703+ """
1704+ if _LIBPROC is None :
1705+ return None # can't count fds; skip the ps scan entirely
1706+ pids = _scan_worker_pids ()
1707+ if not pids :
1708+ return None
1709+ total = 0
1710+ counted = False
1711+ for pid in pids :
1712+ count = _process_fd_count (pid )
1713+ if count is not None :
1714+ total += count
1715+ counted = True
1716+ return total if counted else None
1717+
1718+
16561719def cap_log (log_path : Path , max_bytes : int = LOG_MAX_BYTES ) -> bool :
16571720 """Cap a service log in place once it exceeds max_bytes, preserving the last
16581721 LOG_KEEP_TAIL_LINES lines of context. Returns True if it rotated.
@@ -3686,7 +3749,7 @@ def cmd_start(args):
36863749 # /build link points to our build-data dir (set up during setup).
36873750 # Required for Immich 2.7+ plugin WASM paths stored in the shared DB.
36883751 build_data = DATA_DIR / "build-data"
3689- has_plugins = (build_data / "corePlugin" / "manifest.json" ). exists ( )
3752+ has_plugins = _build_has_core_plugin (build_data )
36903753
36913754 if _build_link_ok ():
36923755 pass # /build resolves correctly, both Docker and native see the same paths
@@ -3954,13 +4017,42 @@ def cmd_update(_args):
39544017 log .info ("Updated to %s. Run: python -m immich_accelerator start" , running )
39554018
39564019
4020+ def _int_env (name : str , default : int ) -> int :
4021+ """Parse an int env var, falling back to default on a missing/bad value.
4022+
4023+ Must never raise at import: these are module-level constants, so a bad
4024+ value would otherwise break every subcommand, not just the watcher.
4025+ """
4026+ try :
4027+ return int (os .environ .get (name , str (default )))
4028+ except (ValueError , TypeError ):
4029+ return default
4030+
4031+
4032+ # fd-leak safety net (#89): Immich leaks file handles processing some media
4033+ # (e.g. certain Sony XAVC files on an external drive), opening each source file
4034+ # and never closing it. Once the fd table gets huge, macOS starts failing
4035+ # spawn() with EBADF and the worker crashes. A healthy worker sits around 150
4036+ # open fds; restart it well before the wall. 0 disables the check.
4037+ FD_RESTART_THRESHOLD = _int_env ("IMMICH_ACCEL_FD_RESTART_THRESHOLD" , 10000 )
4038+ # Minimum seconds between fd-triggered restarts, so a fast leak (or a high
4039+ # post-restart baseline) can't thrash the worker with back-to-back restarts.
4040+ FD_RESTART_COOLDOWN = _int_env ("IMMICH_ACCEL_FD_RESTART_COOLDOWN" , 300 )
4041+
4042+
39574043def cmd_watch (_args ):
39584044 """Monitor services and restart on crash. Detects Docker updates.
39594045
39604046 Suitable for launchd KeepAlive — runs forever, checking every 30s.
39614047 """
39624048 log .info ("Watching services (Ctrl+C to stop)..." )
39634049
4050+ if FD_RESTART_THRESHOLD > 0 and _LIBPROC is None :
4051+ log .warning (
4052+ "fd-leak watchdog (#89) inactive: libproc unavailable, cannot read "
4053+ "worker fd counts on this system."
4054+ )
4055+
39644056 # `brew services stop`/`restart` send SIGTERM to this watcher, but the
39654057 # worker/ML/dashboard run detached (own session) and would survive — so a
39664058 # plain stop/restart left them running (#81). Trap the signal and stop the
@@ -4012,10 +4104,15 @@ def _graceful_stop(signum, _frame):
40124104 check_count = 0
40134105 self_update_notified = False
40144106 shown_worker_hints : set [str ] = set ()
4107+ # None until the fd watchdog first restarts the worker. Compared against
4108+ # time.monotonic() (uptime), so a 0.0 sentinel would wrongly read as "just
4109+ # restarted" for the first FD_RESTART_COOLDOWN seconds after boot.
4110+ last_fd_restart : float | None = None
40154111 while True :
40164112 try :
40174113 time .sleep (30 )
40184114 config = load_config () # reload each cycle (setup may have changed it)
4115+ worker_handled = False # set by the fd watchdog to skip crash-check
40194116
40204117 # Auto-apply a `brew upgrade`: if the version installed on disk no
40214118 # longer matches the code we're running, stop the (now-stale)
@@ -4060,8 +4157,49 @@ def _graceful_stop(signum, _frame):
40604157 except RuntimeError :
40614158 log .error (" ML restart failed" )
40624159
4160+ # fd-leak safety net (#89): restart the worker before a runaway
4161+ # open-file-descriptor count (an upstream Immich leak on some media)
4162+ # exhausts the table and crashes it with `spawn EBADF`. Sum fds
4163+ # across all worker processes (the leak may be in a sibling), and
4164+ # cool down between restarts so a fast leak can't thrash.
4165+ if FD_RESTART_THRESHOLD > 0 :
4166+ fd_total = _worker_fd_total ()
4167+ if fd_total is not None and fd_total >= FD_RESTART_THRESHOLD :
4168+ cooled = (
4169+ last_fd_restart is None
4170+ or time .monotonic () - last_fd_restart >= FD_RESTART_COOLDOWN
4171+ )
4172+ if cooled :
4173+ last_fd_restart = time .monotonic ()
4174+ log .warning (
4175+ "Worker fd count %d exceeds %d: Immich leaks file "
4176+ "handles on some media (#89). Restarting the worker "
4177+ "before it exhausts the fd table and crashes." ,
4178+ fd_total ,
4179+ FD_RESTART_THRESHOLD ,
4180+ )
4181+ kill_pid ("worker" )
4182+ try :
4183+ cmd_start (argparse .Namespace (force = True ))
4184+ except RuntimeError :
4185+ log .error (" Worker restart after fd leak failed" )
4186+ # We handled the worker: skip the crash-check below so it
4187+ # can't also log "Worker crashed" and double-start this
4188+ # cycle. We do NOT `continue` (that would starve the
4189+ # update-detection cadence); if cmd_start early-returned
4190+ # without a live worker, next cycle's crash-check
4191+ # restarts it.
4192+ worker_handled = True
4193+ else :
4194+ log .debug (
4195+ "Worker fd count %d high but within restart "
4196+ "cooldown (%ds); not restarting yet." ,
4197+ fd_total ,
4198+ FD_RESTART_COOLDOWN ,
4199+ )
4200+
40634201 # Check worker
4064- if not read_pid ("worker" ):
4202+ if not worker_handled and not read_pid ("worker" ):
40654203 # Surface a known unrecoverable cause once (e.g. a fresh
40664204 # split-deploy geodata import failing) instead of looping
40674205 # silently — restarting won't fix it until the user acts.
@@ -4077,9 +4215,12 @@ def _graceful_stop(signum, _frame):
40774215 except RuntimeError :
40784216 log .error (" Worker restart failed, will retry in 30s" )
40794217
4080- # Every 5 min, check if Immich updated
4218+ # Every 5 min, check if Immich updated. Skip on a cycle where the
4219+ # fd watchdog just restarted the worker, so we don't tear it back
4220+ # down (cmd_stop + re-extract) in the same tick; check_count stays
4221+ # >=10 so it runs next cycle instead.
40814222 check_count += 1
4082- if check_count >= 10 :
4223+ if check_count >= 10 and not worker_handled :
40834224 check_count = 0
40844225 try :
40854226 cached = config .get ("version" , "" ).lstrip ("v" )
0 commit comments