Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

972 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Resource cleanup in LDR
This document captures how LDR manages process-level resources (DB
connections, HTTP clients, file descriptors, threads) and the reasoning
trail behind the current model. It exists because file-descriptor
exhaustion has been a recurring class of bug in LDR, and the *journey*
of fixing it — what's been tried, what worked, what was ruled out — is
not reconstructable from `git log` alone.
If you're contributing code that holds a network connection, a database
session, an LLM client, or a thread, read this before adding `__del__`,
`weakref.finalize`, or a context manager.
---
## Current model
### Database connections
- **One shared per-user `QueuePool`.** No per-thread engines. Pool
sizing: `pool_size=20`, `max_overflow=40`, with periodic `dispose()`
every 30 minutes.
- **SQLCipher is decrypted once per connection-open.** `PRAGMA key`
takes ~0.2 ms; pool reuse keeps that off the hot path.
- Engines are created at login, closed at logout (or process exit via
the registered `atexit` shutdown).
- Background threads (research workers, metric writers, news scheduler
jobs) use the same per-user pool — they no longer maintain a separate
thread-engine system.
See [ADR-0004](../decisions/0004-nullpool-for-sqlcipher.md) for the
QueuePool-vs-NullPool decision and PR #3441 for the per-thread-engine
removal.
### LLM wrappers
LDR wraps every LLM in `ProcessingLLMWrapper` (and optionally
`RateLimitedLLMWrapper`) so that callers see a uniform interface and
the project owns the close path:
```
caller -> ProcessingLLMWrapper.close()
-> _close_base_llm(base_llm) in utilities/llm_utils.py
-> for ChatOllama:
sync httpx client (ollama.Client._client) .close()
async httpx client (ollama.AsyncClient._client) .aclose()
-> for ChatOpenAI / ChatAnthropic:
no close (those use @lru_cache'd shared httpx clients)
```
Key invariants:
- `ChatOllama` is the *only* provider where `_close_base_llm()` actually
closes anything. ChatOpenAI and ChatAnthropic share LRU-cached httpx
clients across instances; closing them would break other live LLMs.
- Both `_client` (sync) **and** `_async_client` (async) are released —
the async side is exercised by every `ainvoke()` call (langgraph
agents, modular strategies). Closing only the sync side leaks the
async transport per call (root cause of #3816).
- The function is idempotent via an `_ldr_closed` sentinel on the inner
httpx clients.
- The async close uses `asyncio.run(client.aclose())` only when no
event loop is currently running. When called from inside async code
it skips and leaves the close to the loop's owner.
### Search engines
- `BaseSearchEngine.close()` is the single entry point and **cascades**
into `_preview_filters` and `_content_filters`. That cascade is what
releases per-engine LLMs (e.g., `JournalReputationFilter.model`),
SearXNG sessions, and other filter-held resources.
- Search-engine cleanup happens at the per-research finally block in
`web/services/research_service.py:run_research_process()` and at the
programmatic API entry points in `api/research_functions.py`.
- The `_owns_llm` flag pattern (introduced in #2712) tracks whether a
filter or engine constructed its own LLM (and thus owns it) versus
borrowed one from a caller (and must not close it).
### Thread lifecycle
- `@thread_cleanup` (decorator on `run_research_process` and similar
workers) ensures thread-local DB sessions are released even on
abnormal exits.
- `cleanup_current_thread()` is called from Flask teardown, the queue
processor, the auth flow, and the RAG routes — six tier-1 paths in
total.
- Background threads are daemon threads; the process exit handles any
thread that did not clean up gracefully.
### Conventions
- **Use `safe_close(resource, "human name")`** from
`utilities/resource_utils.py` for every cleanup. Never bare `.close()`
in a `finally` (it can mask the original exception).
- **Prefer `try/finally` over `__del__`**. Python doesn't guarantee
finalization order at interpreter exit; `__del__` interacts subtly
with reference cycles and `weakref`.
- **Track ownership explicitly with `_owns_llm` (or analogous flag)**
when a class accepts an injected resource that may or may not be its
own.
- **News fragments (`changelog.d/<id>.bugfix.md`) are required for any
user-visible cleanup behavior change** — see `changelog.d/README.md`.
---
## How to close X correctly
| You're holding | Do this |
| --- | --- |
| A `ChatOllama` (raw or wrapped) | Call `wrapper.close()` in a `finally`, or pass to `safe_close(wrapper, "...")`. The wrapper chain handles both sync and async httpx clients. |
| A search engine you constructed | `safe_close(engine, "...")` in `finally`. The engine's `close()` cascades into preview/content filters. |
| A holder class with an LLM | Add a `close()` method, gate the LLM close on `self._owns_llm`, document who calls it. Don't add `__del__`. |
| A long-lived service holder (news scheduler, etc.) | Wrap construction in `try/finally` at the cycle boundary. Don't store the LLM if you can recreate it cheaply. |
| A DB session | Use `with get_user_db_session(username) as session:`. Don't bypass via `get_settings_manager(username=...)` without `owns_session=False` (see #3023). |
| An asyncio event loop | Use the existing one. If you genuinely need a new one (background thread fallback), call `loop.close()` in a `finally` — see `news_strategy.py` for the reference pattern (post-#3018). |
---
## Anti-patterns
These look reasonable but break specific things in this codebase:
- **Adding `__del__` to a class with `close()`.** At interpreter exit
the `logger`, `httpx`, and event-loop modules may already be torn
down. `__del__` can run after them and raise. Use explicit close in
a `finally` instead.
- **Closing a shared httpx client.** ChatOpenAI / ChatAnthropic share
one httpx pool across instances via `@lru_cache`. Closing it kills
every other live LLM in the same process. The Ollama check in
`_close_base_llm` exists exactly to gate this.
- **Truthy idempotency sentinels on Mock objects.** `Mock()` without a
`spec` auto-generates child Mocks for any attribute access, so
`getattr(client, "_ldr_closed", False)` returns a truthy Mock and
short-circuits the close. Always use `is True` / `is None` checks
for sentinels — see the pattern in `_close_base_llm`.
- **Skipping `super().close()` in a search-engine subclass.**
`BaseSearchEngine.close()` is what cascades into preview/content
filters. Override it without calling super and you leak every
filter's resources (this was a Copilot finding on #3818).
- **Treating `asyncio.run()` as safe inside an event loop.** It raises
`RuntimeError` if called from a thread that already has a running
loop. The pattern in `_close_base_llm` is: detect a running loop
with `get_running_loop()`, skip the async close in that branch (the
loop owner will close), only call `asyncio.run` in the no-loop case.
---
## History
The FD-leak campaign spans roughly four months of iterative work. Each
fix narrowed the remaining surface; each subsequent issue was found in
a corner the previous wave hadn't touched.
### Wave 1 — initial leak inventory (Jan 2026)
- **#1832, #1849, #1856, #1860** — first comprehensive sweep. Identified
seven distinct leak sources: `auth_db` engine, `download_management`
DB, search cache, subprocess zombies, HTTP sessions in
`SemanticScholarSearchEngine` and `BaseDownloader`, Socket.IO threads.
Established context-manager + `try/finally` patterns. Added a
pre-commit hook to catch missing cleanup at commit time.
### Wave 2 — thread-local engine accumulation (Mar 2026)
- **#2495** — diagnosed that Flask's teardown only cleaned the
request-scoped `g.db_session` while a separate `_thread_engines` dict
accumulated NullPool engines per thread, leaking ~3 FDs per request.
Added `cleanup_current_thread()` across six tier-1 paths.
- **#2591** — dead-thread engines (when threads crashed they left
engines behind) plus `stream=True` socket holds in the generic
downloader. Added a throttled dead-thread sweep, removed `stream=True`,
raised the Docker ulimit from 1024 to 65536.
### Wave 3 — LLM wrapper lifecycle (Mar 2026)
- **#2708** — diagnosed `ChatOllama``httpx.Client` chains with no
`__del__`. With the news scheduler triggering 50300
`quick_summary()` calls per hour, a 1024-FD container exhausted in
34 hours. Wrapped four programmatic API entry points in
`try/finally` with explicit close.
- **#2712** — extracted `close_llm()` to a shared utility. Added
`close()` and `_owns_llm` to `NewsAnalyzer`, `HeadlineGenerator`,
`TopicGenerator`, `JournalReputationFilter`, `DomainClassifier`,
`GitHubSearchEngine`, `IntegratedReportGenerator`,
`ElasticsearchSearchEngine`, and the benchmark graders.
- **#2756** — wrapped bare `.close()` calls in `finally` blocks with
`safe_close()` to prevent masking the original exception.
- **#2732** — moved `close()` into `ProcessingLLMWrapper` and
`RateLimitedLLMWrapper` directly; eliminated the standalone
`close_llm()` free function.
### Wave 4 — DB session leaks + per-call patterns (late Mar / early Apr 2026)
- **#3018** — `get_settings_manager(username=...)` was bypassing
`g.db_session` and creating QueuePool sessions per-thread; live
diagnostics showed 321 sockets allocated, only 66 in use.
`DownloadService.close()` leaked the inner `SettingsManager` session.
Also fixed `TopicBasedRecommender._create_recommendation_card()`
(per-call LLM with no cleanup) and an `asyncio.new_event_loop()` in
`news_strategy.py` that never closed.
- **#3204** — test fixtures using `return` instead of `yield` left
engines un-disposed. Migrated 8 test files to `yield` +
`engine.dispose()`.
### Wave 5 — DB pool architecture (Apr 2026)
- **#3340** — kept QueuePool but minimized FDs (`pool_size=1`,
`max_overflow=2`, periodic `dispose()` every 30 min).
- **#3337** (closed) — proposed switching SQLCipher engines to
NullPool for zero persistent FDs. Superseded by #3441.
- **#3441** — removed per-thread NullPool engines entirely
(~2,100 lines of sweep logic deleted) and routed metrics through a
single shared per-user QueuePool with bounded sizing
(`pool_size=20`, `max_overflow=40`).
- **#3477** — created [ADR-0004](../decisions/0004-nullpool-for-sqlcipher.md)
capturing the final pool model and updated stale FD calculations
across docs.
### Wave 6 — async client close (May 2026)
- **#3818** (open, declined for merge) — proposed session-pooling
around `safe_get`/`safe_post` to address #3816. The session refactor
is reasonable in isolation, but the lsof in #3816 showed ~72% of
leaked FDs as `a_inode [eventpoll]` selectors, not HTTP request
sockets — pointing at async-client transports rather than `safe_get`
callers (whose response bodies were already consumed). See
[the PR comment](https://github.com/LearningCircuit/local-deep-research/pull/3818#issuecomment-4402290677)
for the full reasoning.
- **#3855** — extended `_close_base_llm()` to also close
`ChatOllama._async_client` (the actual gap the lsof pointed to).
Added the `IntegratedReportGenerator` close that was missing from the
per-research `finally` block. Idempotency via `_ldr_closed` sentinels
on the inner httpx clients.
### Wave 7 — async close inside a running loop (May 2026)
- **#4047** — `_close_base_llm`'s async branch had a documented "skip if
a loop is running; loop owner closes" path. **No loop-owner cleanup
code existed anywhere in the project**, so when the close was called
inside an active asyncio loop the inner `httpx.AsyncClient` (and its
`epoll_create` FD) was silently abandoned. Reproduced in production:
a v1.6.10 single-host Ollama container reached 1024 FDs with the
/proc histogram showing **929 `anon_inode:[eventpoll]` (91%)** — the
same FD class as #3816 but in a code path #3855's fix didn't cover.
The fix runs the async close in a brief daemon thread that owns its
own loop, so `asyncio.run(aclose())` works regardless of the caller's
loop state. A bounded 5-second `join` keeps the cleanup from blocking
shutdown when the Ollama server is unresponsive; on timeout
`_ldr_closed` is left unset so a later call retries, and a WARNING
surfaces so the situation is observable instead of silent.
- **Healthcheck pidfd leak (same PR).** Dockerfile's
`HEALTHCHECK CMD python -c "... urllib.request.urlopen(...)"` had no
`timeout=` argument; Docker's 10s timeout SIGKILL'd the `sh -c`
parent but the python child was reparented to PID 1 and hung
forever, each surviving child holding a `pidfd` + TCP socket against
the app. Same /proc dump showed **64 `anon_inode:[pidfd]` (6%)** from
this. Adding `timeout=8` lets the child return/raise inside Docker's
budget so it exits cleanly and gets reaped.
#### Audit ledger — what the broader sweep checked
The PR included a wide audit (50+ parallel exploration agents across
seven rounds plus direct `/proc` inspection) to catch any other latent
FD leak. To save the next contributor from re-running the same checks,
here is the full ledger:
##### Checked and confirmed clean (no action needed)
- **Non-Ollama LLM providers.** xAI, Google Gemini, OpenRouter, IONOS,
LM Studio, llama.cpp HTTP, DeepSeek, OpenAI-compatible endpoint, plus
OpenAI and Anthropic themselves. All extend `ChatOpenAI` or
`ChatAnthropic`, which use `@lru_cache`'d shared httpx clients.
`_close_base_llm`'s short-circuit on these classes is correct by
design — closing them would brick every other live LLM in the
process.
- **HTTP session lifecycle.** Six instantiation sites checked
(`PricingFetcher` aiohttp, `LDRClient` SafeSession, `BaseDownloader`,
`SemanticScholarSearchEngine`, `MCPClient`, `CostCalculator`). All
context-managed via `with` or owned by a class with a paired
`close()` and `__exit__`.
- **subprocess / pidfd.** Three call sites, all `subprocess.run()`
(blocking). No `subprocess.Popen` paths anywhere in `src/`. No
`ProcessPoolExecutor`. No FD leak surface beyond the healthcheck
child, already addressed by the Dockerfile `timeout=8` change.
- **asyncio event loops.** Zero raw `asyncio.new_event_loop()`
outside safe `asyncio.run()` patterns. The historical leak in
`news_strategy.py` (#3018) is still fixed.
- **File handles.** All 37 `open()` call sites are inside `with`.
Zero bare opens. `tempfile.NamedTemporaryFile` / `TemporaryDirectory`
all context-managed.
- **SocketIO connect/disconnect.** Non-disconnect handlers
(`subscribe`, `unsubscribe`, `connect`) do not acquire DB sessions
(an early-round agent claim that they did was refuted on re-read).
The `__socket_subscriptions` dict is cleaned on disconnect. The
PID-1 FD breakdown showed only 3 sockets out of 1024 — socket
accumulation is not a contributor.
##### Flagged by audit, then verified NOT a real FD leak
- **OllamaEmbeddings httpx (historical — current state covered in
Wave 10 below).** At the time of this Wave-7 audit LDR imported the
**deprecated** `langchain_community.embeddings.OllamaEmbeddings`,
which used `requests.post()` per call — no persistent httpx client,
no `_client` / `_async_client` attribute. Direct introspection:
`[a for a in dir(e) if 'client' in a.lower()]` returned `[]`. Zero
FDs per call. An audit agent confused this class with `ChatOllama`,
which is a different class. The migration to
`langchain_ollama.OllamaEmbeddings` predicted in the next subsection
has since shipped (#4352/#4353) and the resulting FD-leak regression
has been fixed — see Wave 10.
- **`auth_db` and `journal_quality` engines escaping
`shutdown_databases()`.** `auth_db` uses
`QueuePool(pool_size=10, max_overflow=20)` and `journal_quality`
uses `StaticPool` with `immutable=1`. Both are **bounded** and do
not grow at runtime. Live `/proc` on the affected container showed
only 21 SQLite-related FDs total on PID 1 — well below the ~91-FD
ceiling these unmanaged engines could theoretically reach. The
kernel reclaims FDs at process exit regardless of `engine.dispose()`,
and SQLite WAL files auto-checkpoint on next open. Missing dispose
at exit is hygiene, not a leak.
- **`LibraryRAGService` in three RAG SSE endpoints.**
`rag_routes.py:693, 1054, 1827` do construct the service outside
the generator and never close it, **but** `LibraryRAGService.close()`
only sets references to `None` — it releases no FDs. FAISS uses
`pickle.load()` (not mmap); OllamaEmbeddings holds no FDs per the
item above; the SentenceTransformer model+tokenizer mmaps are
process-wide singletons. What gets delayed is ~50200 MB of
embedding-model RAM until GC. A memory-pressure question, not the
eventpoll FD class this Wave addressed.
- **Residual `pidfd` accumulation via Playwright fallback** —
identified in a Round-8 follow-up after the eventpoll fix landed.
Live `/proc` on the prerelease container showed ~29 pidfds steady
state, growing ~3.6/hour, all targeting `Pid: -1` (children that
had exited). Rate was stable during active benchmark execution,
ruling out a per-task source. Eight parallel agents converged on
the same chain: `_check_subscription``quick_summary`
`FullSearchResults.batch_fetch_and_extract``AutoHTMLDownloader`
fallback to `PlaywrightHTMLDownloader._fetch_with_playwright`. Each
`sync_playwright().start()` invokes
`asyncio.create_subprocess_exec()` for the Node.js driver (opens a
pidfd via Linux's `PidfdChildWatcher`); the driver then fails
because Chromium is not installed in the production `ldr` Dockerfile
stage (only `ldr-test` runs `playwright install --with-deps
chromium`), and the asyncio child watcher does not promptly close
the pidfd on the failed-child exit. CPython 3.14 was confirmed to
not use pidfd in `subprocess.py` at all (`subprocess.run`/`Popen`
use `waitpid(WNOHANG)` polling), so subprocess-based hypotheses
were ruled out. **Fixed by PR #3971** (default
`web.enable_javascript_rendering=false`): the fallback short-circuits
before any subprocess is spawned, so no pidfd is opened. The PR was
motivated by issue #3826 (confusing tracebacks); the FD-leak
finding is the second motivation, surfaced here.
##### Minor findings (not steady-state leaks; worth knowing)
- **Daemon threads without explicit shutdown.**
`journal_reputation_filter.py` background fetcher, `log_utils.py`
queue processor. All daemonized — reaped by the OS at process exit.
Not steady-state leaks; no per-request growth.
- **Abandoned-research thread on socket disconnect.** If a client
closes the tab mid-research, the socket subscription is removed but
the research thread keeps running until completion;
`_active_research[research_id]` is not cleared on disconnect. Not an
FD leak; potentially compute/memory waste if the user wanted the
research to stop. Out of scope for the FD-leak story.
#### Future-proofing note — `langchain_ollama.OllamaEmbeddings` migration (resolved in Wave 10)
Status: **resolved**. The migration this note predicted shipped in
#4352/#4353; the FD-leak regression it predicted then surfaced and was
fixed in Wave 10 (see below). Kept here as the source of the prediction
that the next contributor's audit can cross-reference.
`langchain_community.embeddings.OllamaEmbeddings` was deprecated ("will
be removed in langchain 1.0.0", per the import warning). Its replacement,
`langchain_ollama.OllamaEmbeddings`, **does** carry `_client` and
`_async_client` attributes — same shape as `ChatOllama`. Verified by
direct introspection at the time of writing:
```
langchain_ollama.OllamaEmbeddings client attrs:
['_set_clients', 'async_client_kwargs', 'client_kwargs',
'sync_client_kwargs']
Has _client? True
Has _async_client? True
```
The prediction was: once LDR migrates, the eventpoll FD leak class
returns for embeddings unless `_close_base_llm` is called on embedding
instances. The introspection turned out to be slightly different from
expected — both clients are constructed *eagerly* by a Pydantic
`@model_validator(mode="after")` in `langchain_ollama.embeddings.py`,
so the leak fires per-instance regardless of whether the async path is
exercised. Wave 10 contains the post-mortem and fix.
### Wave 10 — embeddings FD leak after langchain_ollama migration (June 2026)
The migration predicted above shipped without the matching close-path
generalization, exactly as feared. Verified by four independent agents:
`langchain_ollama.OllamaEmbeddings(...)` eagerly constructs both a sync
`ollama.Client` (→ `httpx.Client`) and an async `ollama.AsyncClient`
(→ `httpx.AsyncClient` → one `epoll_create` FD) inside its
`@model_validator(mode="after")` at
`.venv/.../langchain_ollama/embeddings.py:295-315`. No `close()`,
`aclose()`, `__del__`, or `weakref.finalize` exists on the new class or
the underlying `ollama` / `httpx` clients, so dropping the Python
reference does not release the FDs.
`_close_base_llm` already handled the shape — its module-prefix checks
(`type(...).__module__.startswith("ollama")` at
`src/local_deep_research/utilities/llm_utils.py:97,114`) match
`ollama.Client` / `ollama.AsyncClient` regardless of which langchain
wrapper holds them. The function just wasn't called on embeddings
instances — `LocalEmbeddingManager.close()` and `LibraryRAGService.close()`
only nulled their `_embeddings` / `embedding_manager` references,
relying on GC that would never run the close.
Fix: route the close call through the existing manager lifecycle.
`LocalEmbeddingManager.close()` now calls `_close_base_llm(self._embeddings)`
before nulling. `LibraryRAGService.close()` now calls
`self.embedding_manager.close()` before nulling — guarded by an
`_owns_embedding_manager` flag so a caller-supplied manager (test
fixtures, multi-service callers) stays under caller control. The
`_close_base_llm` docstring is updated to acknowledge it also handles
`OllamaLLM` and `OllamaEmbeddings`; no behaviour change, only
documentation. Regression coverage lives next to the existing
ChatOllama tests in `tests/utilities/test_close_base_llm.py`
`TestCloseBaseLLMRealOllamaEmbeddings` is the canary that fires if a
future migration breaks the close path again.
A follow-up PR (PR-B) hardens the `rag_routes.py` call sites that
construct `LibraryRAGService` without a `with` block: 4 simple
synchronous sites get a `with` wrap; 3 SSE-streaming sites have the
construction moved *inside* the `stream_with_context` generator (a
`with` at request-handler scope would close the service before the
stream runs). A safety-net PR (PR-C) registers a `weakref.finalize`
inside `OllamaEmbeddingsProvider.create_embeddings()` so that callers
that bypass the manager — for example the programmatic-API examples
migrated in #4399 — still get eventual cleanup at GC time.
### Round 9 — broader resource audit (May 2026)
Once the FD-leak classes were closed, a follow-up audit looked for
*other* slow-growth patterns that wouldn't trip the FD counters but
could still degrade a long-running container: memory and cache growth,
thread / asyncio Task / lock lifecycle, DB state hygiene beyond
connections. Three parallel agents per round, two rounds (Round 1
hypothesis generation, Round 2 fact-check), captured here in
verified form so the next contributor doesn't re-derive the same
conclusions.
#### Refuted (false positives from Round 1, verified in Round 2)
- **`@cache` on `get_available_providers`** (was in `config/llm_config.py`;
**removed in #4590**, so this no longer exists). Round 1 claimed unbounded
cache growth if the function were called with differing `settings_snapshot`
dicts. Round 2 verified: dicts are unhashable, so `@cache` would raise
`TypeError` on them, not silently grow. In practice the call sites passed
`settings_snapshot=None` (hashable, cardinality 1). Not a leak — and the
function (a dead duplicate of the provider auto-discovery path) has since
been deleted entirely. Kept here for the audit record.
- **Thread-local Session identity-map growth**
(`database/thread_local_session.py`). Round 1 claimed long-running
research threads would accumulate ORM objects in the per-thread
Session's identity map. Round 2 verified: SQLAlchemy's default
`expire_on_commit=True` clears the identity map at every commit;
the codebase commits periodically. Bounded by typical query volume,
not unbounded by uptime.
- **`token_usage` table unbounded growth.** Append-only per LLM call
with no TTL or retention job. Round 2 verified: **feature by
design**. Schema has compound time-series indexes
(`idx_token_research_timestamp`, etc.); `/api/context-overflow` and
`/metrics/api/metrics` explicitly query historical windows for cost
analysis. The table is a permanent audit trail by intent. Adding
retention would break the metrics dashboards.
- **`search_calls` table unbounded growth.** Same shape and same
verdict — compound time-series indexes confirm intentional design
as a permanent search-analytics record.
#### Fixed in this PR — three per-user lock dicts
- **Three per-user lock dicts** — `_user_init_locks` and `_user_locks`
are module-level dicts in `database/library_init.py` and
`database/backup/backup_service.py` respectively; `_user_critical_locks`
is an instance attribute on the `QueueProcessorV2` singleton in
`web/queue/processor_v2.py`. Each stored one `threading.Lock` per
username with no removal hook. Bounded ceiling (~296 bytes/entry ×
3 dicts at 1000 users = ~900 KB), so not urgent — but easy to fix
cleanly. The two module-level dicts now expose
`pop_user_init_lock` / `pop_user_lock` functions; the queue
processor exposes the equivalent as an instance method
`queue_processor.pop_user_critical_lock`. A shared
`_pop_per_user_locks(username)` helper in `connection_cleanup.py`
calls all three with lazy imports and individual try/except
(WARNING-level so dict accumulation is observable, matching the
sibling scheduler-unregister error path). The helper is invoked
unconditionally — outside the `close_user_database` try/except so
it still runs when the DB close itself fails — in both the
idle-connection sweeper (`connection_cleanup.py:cleanup_idle_connections`)
and the logout / password-change paths (`web/auth/routes.py`).
Tests in `tests/web/auth/test_connection_cleanup.py::TestPopPerUserLocks`
cover the helper directly and through the idle-close path.
#### Real but small (survives verification)
- **`app_logs` (ResearchLog) table — no automatic retention.** Grows
by ~100s-1000s of rows per research. Cleaned only via cascade-delete
when the parent `Research` row is deleted manually. Unlike
`token_usage` / `search_calls`, this table has no UI dashboard or
time-series API consuming it — it's debug context for a specific
research session, not an analytics record. For users who keep all
research, logs accumulate indefinitely. See "Intentionally not done
(deferred)" for the retention design when a symptom report
justifies it.
---
## Debugging FD leaks — playbook for the next one
When the next FD leak shows up (and there will be one, eventually), this
section is the shortcut. It captures the actual diagnostic flow that
worked across Waves 6 and 7 so a future contributor doesn't have to
re-derive it from the symptom.
### 0. Symptoms that mean "investigate this as an FD leak"
- Tracebacks like `OSError: [Errno 24] Too many open files`, typically
from `selectors.DefaultSelector()` in werkzeug or `send_from_directory`
in Flask. These are usually the *first* visible failure.
- Browser-side MIME-type errors on static assets (`text/html` instead of
`text/css` / `application/javascript`). These are downstream of FD
exhaustion — Flask can't open the static file, returns an HTML 500,
and the browser refuses to apply it because of
`X-Content-Type-Options: nosniff`.
- `High FD count (N) — approaching system limit` warnings from
`web/auth/connection_cleanup.py` (fires at FD > 800 every 5-minute
cleanup tick).
- Container health turns `unhealthy` because the healthcheck `urlopen`
hangs on a process that no longer has FDs to accept connections.
### 0a. Rule out first — local UI-test "fresh-user churn" false positive
Before treating climbing FDs as a leak, confirm you are measuring the
**single-CI-user** condition. A very convincing *false* FD leak appears
when reproducing UI tests locally:
- The Puppeteer harness (`tests/ui_tests/auth_helper.js`
`ensureAuthenticated`) logs in as the shared CI user `test_admin` when
`CI=true`. If that login fails, it **falls back to registering a fresh
`testuser_<timestamp>` per test**. The usual local trigger is
`test_admin` getting *failed-login lockout-locked* after a few
iterations.
- Each fresh user opens its own per-user encrypted DB + engine. Those are
disposed only on logout or the ~300s connection-cleanup sweep, so within
one sub-300s shard run they accumulate and the server's FD count to
`encrypted_databases/*.db(-wal/-shm)` climbs ~linearly (e.g. 0→90 per
shard run, 0→533 over six runs). It looks identical to a real per-user
connection leak.
- It is **not** a server bug. In real CI the one working `test_admin` is
reused → one engine → FDs bounded by the pool cap (pool_size 20 +
max_overflow 40 = 60). Confirm by grepping the server log for many
distinct `testuser_<ts>` engine opens, or by checking the username the
leaked FDs' DB files belong to.
Concretely: the **chat UI shards** (`chat-core`, `chat-lifecycle`) failing
in CI were investigated as a per-user DB FD leak and traced *twice* to
this artifact. Both shards pass locally in faithful CI mode with bounded
FDs; their CI failures are runner **contention** (60s navigation timeouts
on a heavily-loaded Docker runner), not a connection leak. Cross-verify
the user identity before committing to a leak hypothesis.
### 1. Capture diagnostic state BEFORE restarting
The single most important rule: **the snapshot does not survive a
container restart**. Every minute spent on the live broken container is
worth an hour of after-the-fact agent guessing. Save the diagnostic
output to a host-side file first.
#### One-shot host-side snapshot (works even when the container is
FD-starved enough that `docker exec` can't fork)
```bash
# Run on the Docker host. No docker exec required.
P=$(docker inspect -f '{{.State.Pid}}' <container-name>)
sudo bash -c "
echo '=== Total FDs ==='
ls /proc/$P/fd | wc -l
echo '=== FD-type histogram (digits collapsed) ==='
ls -l /proc/$P/fd | awk '{print \$NF}' \
| sed -E 's/\[[0-9]+\]/[N]/g; s/[0-9]{4,}/NUM/g' \
| sort | uniq -c | sort -rn | head -30
echo '=== Counts by category ==='
printf 'socket: %s\n' \$(find /proc/$P/fd -lname 'socket:*' | wc -l)
printf 'pipe: %s\n' \$(find /proc/$P/fd -lname 'pipe:*' | wc -l)
printf 'eventpoll: %s\n' \$(find /proc/$P/fd -lname '*eventpoll*' | wc -l)
printf 'pidfd: %s\n' \$(find /proc/$P/fd -lname '*pidfd*' | wc -l)
printf 'WAL files: %s\n' \$(find /proc/$P/fd -lname '*-wal' | wc -l)
printf 'SHM files: %s\n' \$(find /proc/$P/fd -lname '*-shm' | wc -l)
printf '.db files: %s\n' \$(find /proc/$P/fd -lname '*.db' | wc -l)
" | tee /tmp/ldr-fd-snapshot.txt
```
Why host-side: reading the container's PID 1 FDs from inside the
container requires the same UID that started PID 1. The Dockerfile
entrypoint runs as root then `setpriv`s to `ldruser`, so the
`docker exec` shell (ldruser) cannot `readlink` PID 1's FDs even though
it can count them. Host root via `sudo` sidesteps the UID check.
#### Inside-container alternative (if the host is locked down)
```bash
docker exec --user 0 <container-name> sh -c '...same body...'
```
`--user 0` runs the exec'd shell as root inside the container,
sidestepping the same UID restriction.
### 2. The lookup table — FD type → likely source
| Dominant FD type | Likely source | Diagnostic deep-dive |
|-----------------------------|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
| `anon_inode:[eventpoll]` | `asyncio` event loop or `httpx.AsyncClient` selector. Each leaked async client = +1. | Grep `asyncio.create_subprocess`, `httpx.AsyncClient`, `_async_client`, `ainvoke`. See Wave 6, Wave 7. |
| `anon_inode:[pidfd]` | `asyncio.create_subprocess_*` or `multiprocessing.Process` (uses `pidfd_open` on Linux). | Read `/proc/PID/fdinfo/N` for each pidfd; the `Pid:` line shows the target (`-1` = child already exited). |
| `socket:*` (lots) | HTTP keep-alive, SSE streams, SocketIO connections. | Cross-reference with `/proc/PID/net/tcp` states; check Round 7 R7A8 patterns. |
| `pipe:*` (lots) | `subprocess.run`/`Popen` with `stdout=PIPE`, multiprocessing IPC, loguru queue. | Check `subprocess.run` sites and APScheduler executor type. |
| `REG` `*-wal` / `*-shm` | SQLCipher in WAL mode. Each pooled connection holds ~3 FDs. | See ADR-0004. If growing without bound, the periodic `engine.dispose()` is silently failing. |
| `REG /data/*.db` (lots) | Plain SQLite connections from an engine without bounded pool. | Audit `create_engine` sites (R7A6 caught two unmanaged ones). |
| `REG /home/...mmap...` | Memory-mapped model weights or FAISS indexes — usually process-wide singletons (not leaks). | Check whether the count grows per request. If yes → real leak. |
### 3. Pinpointing the source for a specific FD type
#### Eventpoll
`anon_inode:[eventpoll]` always comes from `EpollSelector` — created
by every asyncio loop and every `httpx.AsyncClient`. Grep:
```
grep -rn 'asyncio.create_subprocess\|httpx.AsyncClient\|_async_client' src/
```
Then check whether each site explicitly closes the client. The Wave 7
fix to `_close_base_llm` is the reference pattern for "close async
httpx even when called inside a running loop."
#### Pidfd
Pidfds expose their target PID via fdinfo:
```bash
# Run inside the container (or via docker exec --user 0):
for fd in $(ls /proc/1/fd 2>/dev/null); do
link=$(readlink /proc/1/fd/$fd 2>/dev/null)
case "$link" in
*pidfd*)
tpid=$(awk '/^Pid:/ {print $2}' /proc/1/fdinfo/$fd 2>/dev/null)
if [ "$tpid" -gt 0 ] 2>/dev/null; then
cmd=$(tr '\0' ' ' < /proc/$tpid/cmdline 2>/dev/null | cut -c1-80)
echo "fd=$fd alive pid=$tpid : $cmd"
else
echo "fd=$fd ORPHAN (child exited; pidfd not closed)"
fi
;;
esac
done
```
A high "ORPHAN" count = something called `asyncio.create_subprocess_*`
or `multiprocessing.Process`, the child exited, but the pidfd in the
parent was never closed. Common in Round-8: Playwright's Node.js
driver subprocess failing because Chromium isn't installed in the
production image.
**Note:** CPython 3.14's `subprocess.py` does not use pidfd at all
(`waitpid(WNOHANG)` polling instead). So pidfds in a 3.14 process
necessarily come from asyncio or multiprocessing, not from
`subprocess.run` / `Popen`.
#### Syscall-level pinpointing with bpftrace (mysterious cases)
When the source isn't obvious from the FD type, `bpftrace` can record
the Python stack of every relevant syscall on the live process. This
would have caught the Playwright leak in seconds instead of two rounds
of agent exploration. Requires kernel headers and `bpftrace` installed
on the host (NOT the container — bpftrace runs in host kernel space
and can target a host PID by number):
```bash
# Find host-side PID of container's PID 1
P=$(docker inspect -f '{{.State.Pid}}' <container>)
# Trace every pidfd_open syscall, grouped by user-stack:
sudo bpftrace -e "tracepoint:syscalls:sys_enter_pidfd_open
/pid == $P/ { @[ustack(perf)] = count(); }"
# Same idea for epoll_create / epoll_create1 (eventpoll FDs):
sudo bpftrace -e "tracepoint:syscalls:sys_enter_epoll_create1
/pid == $P/ { @[ustack(perf)] = count(); }"
```
Let it run for a minute, then Ctrl-C; you get a histogram of every
unique stack that triggered the syscall, ranked by frequency. The hot
stacks are your culprits. Works for any syscall — useful future
candidates: `socket`, `inotify_init1`, `timerfd_create`,
`memfd_create`.
#### WAL/SHM
`engine.dispose()` is expected to release these. If the count climbs
across the periodic 30-minute dispose cycles, the dispose is silently
failing. The observability commit (f86c3f7af) elevates dispose
failures to WARNING — check the logs for `Error disposing engine for
<user>`.
### 4. Existing instrumentation already in the codebase
- **`_count_open_fds()`** in
`src/local_deep_research/web/auth/connection_cleanup.py`
fast `/proc/self/fd`-based counter with macOS fallback. Reusable.
- **`Resource monitor: open_fds=…`** debug log line in
`connection_cleanup.py`, fires every 5-minute cleanup tick.
- **`High FD count (N)` WARNING** in `connection_cleanup.py`
when FDs exceed 800. The single most useful production signal.
- **`GET /api/v1/health` resource diagnostics** (PR #4915) — for
*authenticated* callers the response carries a `resources` block
(`fd_count`, `fd_soft_limit`, `fd_hard_limit`, `fd_usage_percent`,
`thread_count`) and flips `status` to `"warning"` above 70% FD usage.
This is the live, queryable form of the `_count_open_fds()` log
signal — `curl` it during a leak hunt instead of grepping container
logs. It returns counts only (never fd targets), so no open file
paths or socket peers are exposed. Anonymous callers (the Docker
healthcheck) get only the basic `status`/`message`/`timestamp`.
- **In-CI FD-growth canaries** in
`tests/utilities/test_close_base_llm.py`. These run on every PR:
- `TestCloseBaseLLMRealHttpxAsync::test_no_fd_growth_across_repeated_close_cycles`
— guards the eventpoll FD class against Wave-6-shaped regressions.
- `TestCloseBaseLLMRealHttpxAsync::test_no_fd_growth_when_closed_inside_running_loop`
— guards the Wave-7-shaped in-running-loop skip regression.
- `TestAsyncioSubprocessFDBaseline::test_no_fd_growth_across_asyncio_subprocess_cycles`
— guards the pidfd FD class against the child-watcher leak shape.
- `TestAsyncioSubprocessFDBaseline::test_no_fd_growth_when_subprocess_fails_to_exec`
— pins the *exact* Wave-7-pidfd shape (failed exec, child watcher
must still clean up). Catches platform-level regressions in
Python's asyncio child watcher.
All four use `_open_fd_count()` (also in that file) which reads
`/proc/self/fd` on Linux with an `RLIMIT_NOFILE` fallback on macOS.
Slack is +2 FDs across 510 iterations. A real per-cycle leak would
blow past that.
### 4a. Development-time detection (catch leaks at test time)
Production /proc inspection catches leaks **after** they ship. The
cheapest catch is to make Python itself complain at test time. Three
Python features cooperate to surface unclosed resources during a
normal test run — none of them were on by default during Waves 6 and
7, which is part of why those leaks made it to production.
**`PYTHONASYNCIODEBUG=1` plus `-W default::ResourceWarning`.** When
asyncio debug mode is on, unclosed transports/coroutines emit a
`ResourceWarning` at GC time. The `-W` filter makes Python actually
display them. Together they would have caught the Wave 7 in-running-loop
skip: every leaked `httpx.AsyncClient` produces a visible warning the
first time the GC sweeps after the test fixture exits. From
[the asyncio dev docs](https://docs.python.org/3/library/asyncio-dev.html):
> When a transport is no longer needed, call its `close()` method to
> release resources. ... If a transport or an event loop is not closed
> explicitly, a `ResourceWarning` warning will be emitted in its
> destructor.
To enable in `pyproject.toml` `[tool.pytest.ini_options]`:
```toml
filterwarnings = [
"default::ResourceWarning",
]
env = [
"PYTHONASYNCIODEBUG=1",
]
```
Or in CI for a one-off check:
```bash
PYTHONASYNCIODEBUG=1 python -W default::ResourceWarning -m pytest tests/
```
For a CI gate that **fails** on any leak (more aggressive — use only
on a targeted subset of tests, not the whole suite, because
third-party libraries also emit ResourceWarning):
```toml
filterwarnings = [
"error::ResourceWarning",
]
```
**`python -X dev`.** Enables Python's dev mode, which turns on a
bundle of safety checks including ResourceWarning display, asyncio
debug mode, and warnings as default. Cheap one-flag alternative for
local development; not recommended in production (overhead).
```bash
python -X dev -m pytest tests/
```
**`psutil` for portable FD counting in tests.** Our in-codebase
`_count_open_fds` uses `/proc/self/fd` (Linux-fast path, macOS
fallback). `psutil` is the cross-platform alternative many other
projects use:
- `psutil.Process().num_fds()` — Linux/BSD only; same number as our
helper.
- `psutil.Process().open_files()` — list of named files; gives the
paths for `REG`-type FDs (e.g., `/data/*.db-wal`).
- `psutil.Process().connections(kind='all')` — sockets visible to the
process, with state and remote address.
These are useful in unit tests when you want to assert "no new file
of pattern X is open after the close path runs," and they work on the
macOS dev environments without `/proc`.
**For tracking which Python object holds a leaked FD: `tracemalloc`
+ `objgraph`.** Not FD tools per se, but when a leak is reproducible,
take a `tracemalloc` snapshot before and after the suspect operation
and diff — the new allocation is usually the wrapper holding the FD.
`objgraph.show_backrefs([leaked_obj])` then renders the reference
chain keeping it alive. Both are pure-Python and zero-dependency.
### 5. Why we don't have an automated FD-growth test in CI
Several reasons, weighed during Wave 6 and Wave 7:
- **Per-request FD growth is hard to assert.** Many legitimate
request paths transiently open and close FDs; a noisy delta is the
norm. Distinguishing "leak" from "in-flight" requires a stable
quiescent state, which a CI test doesn't naturally provide.
- **The CI environment spawns its own subprocesses.** pytest,
coverage, gunicorn workers (for some test variants), gh-runner
cleanups — all add their own FDs that pollute the count.
- **PID-namespace differences between CI and prod.** Counts you
observe in a CI container's /proc are not directly comparable to a
production container's /proc; the subprocess sources differ.
- **The actual leaks have been "slow drip" patterns** that need
hours of uptime to surface. Wave 6's eventpoll leak took multiple
hours of `ainvoke` calls to reach the 1024 cap. CI can't run for
hours per PR.
What works instead:
1. **Per-leak unit-level regression tests.** Each fix in Waves 1-7
landed with a targeted test that exercises the specific close path
(e.g. `tests/utilities/test_close_base_llm.py::test_no_fd_growth_when_closed_inside_running_loop`).
These are fast, deterministic, and run on every PR.
2. **Opt-in manual smoke suite** (`RUN_MANUAL_SMOKE=1`) for the
end-to-end "run-the-cycle-N-times-and-count" pattern, used during
investigation but not on every CI run.
3. **Production /proc inspection** when a leak is suspected — the
playbook above. Faster than CI for the long-drip patterns.
If you want to add a long-run CI job, the right shape would be a
**nightly** workflow (not per-PR) that:
1. Builds the production Docker image.
2. Starts it with a synthetic user account and ~5 news subscriptions.
3. Lets it idle for 20-30 minutes.
4. Runs the host-side snapshot script above.
5. Asserts `total FDs < N` and `eventpoll < M` and `pidfd < K`,
where the thresholds are tuned for the steady-state ceilings the
codebase intentionally permits (auth_db pool, etc.).
That would have caught Waves 6, 7 in a single nightly cycle instead
of through a user crash report. The reason it doesn't exist yet is
cost (a half-hour idle job per night per platform) and the lack of a
clear baseline; the Round-8 finding is the moment to consider adding
one if you want to invest the maintenance time.
### 6. Lookup: which Wave fixed which leak class
| FD class | Wave / PR | Root mechanism |
|------------------------|------------------------|---------------------------------------------------------------------------|
| `eventpoll` | Wave 6 #3855 + Wave 7 #4047 | ChatOllama `_async_client` not closed (Wave 6) → also not closed when called inside a running loop (Wave 7). |
| `pidfd` from healthcheck | Wave 7 #4047 | `urlopen` no `timeout=` → child hangs → reparented to PID 1 with pidfd held. |
| `pidfd` from Playwright fallback | Round 8 / #3971 | Production image lacks Chromium binary; Playwright invocation opens pidfd then fails. |
| WAL/SHM accumulation | Wave 5 / ADR-0004 | SQLCipher+WAL leaks handles on out-of-order close; periodic `engine.dispose()` resets the pool. |
| Per-thread engine FDs | Wave 5 #3441 | Removed per-thread `NullPool` engines entirely; shared per-user `QueuePool`. |
| HTTP session sockets | Wave 1 / Wave 3 | `SafeSession` / `BaseDownloader` close-in-`finally` discipline. |
| `asyncio.new_event_loop` | Wave 4 #3018 | Replaced manual loop creation with `asyncio.run()` in `news_strategy.py`. |
Use this table to skip the rediscovery step the next time a specific
FD type dominates a snapshot.
---
## Intentionally not done (deferred)
These showed up during planning and were deliberately *not* done. If
they get rediscovered as "missing work" by future contributors, please
reference this section first.
- **`weakref.finalize` defense-in-depth on the LLM wrappers.** Designed
and verified safe (no `__del__` conflicts, `__getattr__` doesn't
intercept `_finalizer`, no reference cycles). Deferred until a
fourth wave of "missed close" leaks justifies adding a new pattern
that future contributors must understand. Current explicit-close
discipline has held since #2712 / #2732 / #3018.
- **LLM caching in `get_llm()`.** Bounding total `ChatOllama` instances
to N=distinct configs would make leak shapes architecturally
impossible. Orthogonal optimization, deferred — adds complexity
around settings invalidation and multi-tenant isolation.
- **Pre-commit hook flagging `get_llm()` callers without `close()`.**
Useful in principle, deferred — high false-positive risk
(caller-passed LLMs, lazy-init holders, factory-returned LLMs all
legitimately don't close). Needs a careful design.
- **Per-FD-type/inode breakdown on the health endpoint.** The basic
version — aggregate FD count, limits, and usage percent on
`GET /api/v1/health` — shipped in PR #4915 (see section 4). The two
earlier attempts were closed rather than merged: PR #3033 (superseded
by #4915, a clean reapplication onto current main) and PR #3036 (a
`utilities/fd_monitor.py` FD circuit breaker — closed because its
premise, a retry-driven "death spiral," did not match the real
WAL/SHM-handle root cause already handled by the periodic pool
disposal above; `fd_monitor.py` was never merged and does not exist).
A type/inode breakdown (eventpoll vs pidfd vs WAL — the histogram the
section-1 `/proc` snapshot produces) is feasible but deferred until an
active leak hunt actually needs it.
- **Automated reproduction of #3816's eventpoll-FD leak in a test
suite.** Explored in closed PR #3930 — a single-thread
`asyncio.run(ainvoke)` loop against real Ollama does *not* reproduce
eventpoll accumulation, because `asyncio.run` deterministically closes
its loop's selector each call. Reliable reproduction would need
sustained concurrent load (multi-worker harness over a shared loop).
In-CI mock + no-network real `ChatOllama` tests in
`tests/utilities/test_close_base_llm.py` already cover the close-chain
introspection regressions; a load-shape reproduction is deferred
until a future leak justifies the maintenance burden.
- **`app_logs` (ResearchLog) retention setting + scheduled cleanup
job.** Identified in Round 9; the only audit finding that wasn't
refuted but also isn't impactful enough today. *Trigger to do this
work:* a user reports the SQLCipher DB growing >100 MB and
complains about query slowdown, OR a self-hosted instance keeping
research logs for >1 year sees DB bloat, OR the metrics dashboard
starts noting research-detail page load slowdown traced to
`app_logs` joins. *Implementation sketch:* add
`logs.research_log_retention_days` to
`defaults/default_settings.json` (default `0` = disabled, preserves
current behavior; e.g. `30` to keep last 30 days). Extend the
existing `BackgroundJobScheduler` in `scheduler/background.py`
(which already runs `cleanup_inactive_users` hourly and
`_reload_config` every 30 min) with a daily `_cleanup_old_research_logs`
job that deletes `ResearchLog` rows older than the retention
window. Skip rows belonging to favorited / starred researches if a
flag exists. ~30 LOC + a regression test that inserts old rows,
triggers the job, asserts old rows are deleted and recent ones
survive. Add `changelog.d/<id>.feature.md`.
---
## Glossary
- **`_owns_llm`** — instance flag set in `__init__` to `True` when the
class fetched its own LLM via `get_llm()`, `False` when an LLM was
injected by the caller. Gates whether `close()` actually closes the
LLM.
- **`safe_close(resource, name)`** — helper in `utilities/resource_utils.py`
that calls `resource.close()` inside a try/except, logging on failure.
Never raises. Used in every `finally` block.
- **`_ldr_closed`** — sentinel attribute set on inner httpx clients by
`_close_base_llm` to make the function idempotent. Checked with
`is True` (not truthy) so Mock objects without a `spec` don't trip
the guard.
- **eventpoll FD** — Linux `a_inode` file descriptor type for
`epoll_create`'d kernel objects. Each asyncio event loop registers
one. Leaked AsyncClients hold them via the loop's selector.