Skip to content

Commit 84bd516

Browse files
authored
v1.5.25 — fd-leak watchdog: restart worker before EBADF crash (#89)
1 parent 0104c03 commit 84bd516

4 files changed

Lines changed: 247 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.5.25 — 2026-07-05
4+
5+
### Fixed
6+
- **Worker crash from file-descriptor exhaustion** (#89): Immich leaks file handles while processing some media (a reporter hit it on Sony A7IV XAVC files on an external drive), opening each source file and never closing it. His diagnostic (from v1.5.23) showed ~49,000 open descriptors, all pointing at the source files; once the fd table is that large macOS fails `spawn()` with `EBADF` and the worker crashes. The watcher now monitors the worker's live open-fd count (summed across worker processes, via libproc) and restarts the worker before the table gets dangerous (default 10,000; a healthy worker sits around 150), with a cooldown to avoid thrash. Tunable via `IMMICH_ACCEL_FD_RESTART_THRESHOLD` (0 disables) and `IMMICH_ACCEL_FD_RESTART_COOLDOWN`. This is a safety net for an upstream Immich leak, not a fix for the leak itself.
7+
38
## 1.5.24 — 2026-07-03
49

510
### Immich 3.0 support

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.5.24
1+
1.5.25

immich_accelerator/__main__.py

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import argparse
13+
import ctypes
1314
import json
1415
import logging
1516
import 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+
16561719
def 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+
39574043
def 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")

tests/test_accelerator.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,3 +1522,99 @@ def test_false_when_absent(self, tmp_path):
15221522
assert _build_has_core_plugin(tmp_path) is False
15231523
(tmp_path / "plugins").mkdir() # empty plugins dir is not enough
15241524
assert _build_has_core_plugin(tmp_path) is False
1525+
1526+
1527+
class TestProcessFdCount:
1528+
"""fd-count helpers for the #89 fd-leak watchdog (libproc based)."""
1529+
1530+
def test_counts_live_fds_from_buffered_call(self):
1531+
from immich_accelerator.__main__ import _process_fd_count
1532+
from unittest.mock import MagicMock
1533+
1534+
# First call (NULL buffer) returns the capacity high-water (1600 bytes);
1535+
# second call (real buffer) returns the LIVE bytes written (960 -> 120).
1536+
libproc = MagicMock()
1537+
libproc.proc_pidinfo.side_effect = [1600, 960]
1538+
with patch("immich_accelerator.__main__._LIBPROC", libproc):
1539+
assert _process_fd_count(1234) == 120 # live count, not 1600/8
1540+
1541+
def test_none_when_buffered_call_fails(self):
1542+
from immich_accelerator.__main__ import _process_fd_count
1543+
from unittest.mock import MagicMock
1544+
1545+
libproc = MagicMock()
1546+
libproc.proc_pidinfo.side_effect = [1600, 0] # capacity ok, write fails
1547+
with patch("immich_accelerator.__main__._LIBPROC", libproc):
1548+
assert _process_fd_count(1234) is None
1549+
1550+
def test_none_when_libproc_unavailable(self):
1551+
from immich_accelerator.__main__ import _process_fd_count
1552+
1553+
with patch("immich_accelerator.__main__._LIBPROC", None):
1554+
assert _process_fd_count(1234) is None
1555+
1556+
def test_none_on_nonpositive_return(self):
1557+
from immich_accelerator.__main__ import _process_fd_count
1558+
from unittest.mock import MagicMock
1559+
1560+
libproc = MagicMock()
1561+
libproc.proc_pidinfo.return_value = 0 # dead pid / error
1562+
with patch("immich_accelerator.__main__._LIBPROC", libproc):
1563+
assert _process_fd_count(1234) is None
1564+
1565+
def test_worker_fd_total_sums_all_workers(self):
1566+
from immich_accelerator.__main__ import _worker_fd_total
1567+
from unittest.mock import MagicMock
1568+
1569+
# _LIBPROC must be truthy or _worker_fd_total short-circuits (it is None
1570+
# on non-macOS CI, where libproc.dylib doesn't exist).
1571+
with patch("immich_accelerator.__main__._LIBPROC", MagicMock()), patch(
1572+
"immich_accelerator.__main__._scan_worker_pids",
1573+
return_value=[10, 20, 30],
1574+
), patch(
1575+
"immich_accelerator.__main__._process_fd_count",
1576+
side_effect=lambda p: {10: 100, 20: None, 30: 5000}.get(p),
1577+
):
1578+
# 100 + 5000, the None (unreadable pid) is skipped
1579+
assert _worker_fd_total() == 5100
1580+
1581+
def test_worker_fd_total_none_when_no_workers(self):
1582+
from immich_accelerator.__main__ import _worker_fd_total
1583+
from unittest.mock import MagicMock
1584+
1585+
with patch("immich_accelerator.__main__._LIBPROC", MagicMock()), patch(
1586+
"immich_accelerator.__main__._scan_worker_pids", return_value=[]
1587+
):
1588+
assert _worker_fd_total() is None
1589+
1590+
def test_worker_fd_total_none_when_libproc_unavailable(self):
1591+
from immich_accelerator.__main__ import _worker_fd_total
1592+
1593+
# No libproc: skip the ps scan entirely and report None.
1594+
with patch("immich_accelerator.__main__._LIBPROC", None), patch(
1595+
"immich_accelerator.__main__._scan_worker_pids"
1596+
) as scan:
1597+
assert _worker_fd_total() is None
1598+
scan.assert_not_called()
1599+
1600+
1601+
class TestIntEnv:
1602+
"""Safe int env parsing for the fd-watchdog thresholds (#89)."""
1603+
1604+
def test_valid_value(self, monkeypatch):
1605+
from immich_accelerator.__main__ import _int_env
1606+
1607+
monkeypatch.setenv("IAA_TEST_INT", "42")
1608+
assert _int_env("IAA_TEST_INT", 10) == 42
1609+
1610+
def test_missing_falls_back(self, monkeypatch):
1611+
from immich_accelerator.__main__ import _int_env
1612+
1613+
monkeypatch.delenv("IAA_TEST_INT", raising=False)
1614+
assert _int_env("IAA_TEST_INT", 10) == 10
1615+
1616+
def test_bad_value_falls_back_not_raises(self, monkeypatch):
1617+
from immich_accelerator.__main__ import _int_env
1618+
1619+
monkeypatch.setenv("IAA_TEST_INT", "10k")
1620+
assert _int_env("IAA_TEST_INT", 10) == 10

0 commit comments

Comments
 (0)