-
Notifications
You must be signed in to change notification settings - Fork 1
Fix concurrency, SQL injection, and resource leaks #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,11 +2,21 @@ | |
|
|
||
| Holds the singletons the API/sockets operate on and (re)builds the read-side | ||
| components after an index run so freshly indexed data is served immediately. | ||
|
|
||
| The read-side components (db / vectors / embedder / search_engine / chat / | ||
| ask_engine) are exposed as a single immutable snapshot | ||
| (:class:`_ReadState`), referenced atomically by ``self._state``. Public | ||
| attributes ``db``, ``vectors``, ... proxy to the current snapshot via | ||
| ``__getattr__``, so a request thread that captured one of them at the start | ||
| of a request keeps using a consistent set even if :meth:`reload` swaps in a | ||
| new snapshot mid-request. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import threading | ||
| from dataclasses import dataclass | ||
| from typing import Any | ||
|
|
||
| from ..config import Config | ||
| from ..embedding import get_embedder | ||
|
|
@@ -18,31 +28,62 @@ | |
| from ..storage.compsrc import CompSrc | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _ReadState: | ||
| """Immutable bundle of read-side components built together.""" | ||
| db: GraphDB | ||
| vectors: VectorStore | ||
| embedder: Any | ||
| search_engine: SearchEngine | ||
| chat: Any | ||
| ask_engine: AskEngine | ||
|
|
||
|
|
||
| class AppState: | ||
| def __init__(self, cfg: Config): | ||
| self.cfg = cfg | ||
| cfg.ensure_dirs() | ||
| self.bus = EventBus() | ||
| self.index_lock = threading.Lock() | ||
| self.ask_lock = threading.Lock() | ||
| # Guards the *swap* of self._state (writer-side only). Readers do not | ||
| # take this lock; they capture self._state into a local snapshot | ||
| # (which is just a pointer assignment — atomic under the GIL). | ||
| self._reload_lock = threading.Lock() | ||
| self.indexing = False | ||
| self.asking = False | ||
| self.watcher = None | ||
| self.last_extended = None | ||
| self.compsrc = CompSrc(cfg.repo_path) | ||
| self._open() | ||
|
|
||
| def _open(self) -> None: | ||
| self.db = GraphDB(self.cfg.db_path) | ||
| dim = self.db.get_meta("embed_dim", self.cfg.embed_dim) or self.cfg.embed_dim | ||
| self.vectors = VectorStore(self.cfg.vectors_path, dim) | ||
| # Embedder for query-time semantic search (lazy/real or fallback). | ||
| self.embedder = get_embedder(self.cfg) | ||
| self.search_engine = SearchEngine(self.db, self.vectors, self.embedder) | ||
| # Chat model is loaded lazily on first ask; building the engine is cheap. | ||
| self.chat = get_chat(self.cfg) | ||
| self.ask_engine = AskEngine(self.cfg, self.db, self.vectors, | ||
| self.embedder, chat=self.chat) | ||
| self._state: _ReadState = self._build() | ||
|
|
||
| # ---- public attribute proxy ------------------------------------------ | ||
| def __getattr__(self, name: str) -> Any: | ||
| # Only invoked for attributes not found on the instance — i.e. the | ||
| # read-side fields, which live on the current snapshot. Note: each | ||
| # access reads the *current* snapshot. Callers that need a consistent | ||
| # view across multiple fields should call :meth:`snapshot` once. | ||
| if name in _ReadState.__dataclass_fields__: | ||
| return getattr(self._state, name) | ||
| raise AttributeError(name) | ||
|
|
||
| def snapshot(self) -> _ReadState: | ||
| """Return the current read-side snapshot (consistent set of components).""" | ||
| return self._state | ||
|
|
||
| # ---- construction ---------------------------------------------------- | ||
| def _build(self) -> _ReadState: | ||
| """Construct a fresh set of read-side components. Pure: no self mutation.""" | ||
| db = GraphDB(self.cfg.db_path) | ||
| dim = db.get_meta("embed_dim", self.cfg.embed_dim) or self.cfg.embed_dim | ||
| vectors = VectorStore(self.cfg.vectors_path, dim) | ||
| embedder = get_embedder(self.cfg) | ||
| search_engine = SearchEngine(db, vectors, embedder) | ||
| chat = get_chat(self.cfg) | ||
| ask_engine = AskEngine(self.cfg, db, vectors, embedder, chat=chat) | ||
| return _ReadState(db=db, vectors=vectors, embedder=embedder, | ||
| search_engine=search_engine, chat=chat, | ||
| ask_engine=ask_engine) | ||
|
|
||
| def ensure_chat(self): | ||
| """Retry chat-model discovery and keep AskEngine wired to it.""" | ||
|
|
@@ -53,20 +94,43 @@ def ensure_chat(self): | |
| return self.chat | ||
|
Comment on lines
88
to
94
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid direct writes to proxied read-side fields in Line 91 and Line 92 assign 🔧 Proposed fix-from dataclasses import dataclass
+from dataclasses import dataclass, replace
@@
def ensure_chat(self):
- """Retry chat-model discovery and keep AskEngine wired to it."""
- if self.chat is None:
- self.chat = get_chat(self.cfg)
- self.ask_engine = AskEngine(self.cfg, self.db, self.vectors,
- self.embedder, chat=self.chat)
- return self.chat
+ """Retry chat-model discovery and atomically publish updated snapshot."""
+ snap = self._state
+ if snap.chat is not None:
+ return snap.chat
+ chat = get_chat(self.cfg)
+ with self._reload_lock:
+ cur = self._state
+ if cur.chat is None:
+ self._state = replace(
+ cur,
+ chat=chat,
+ ask_engine=AskEngine(
+ self.cfg, cur.db, cur.vectors, cur.embedder, chat=chat
+ ),
+ )
+ return self._state.chat🤖 Prompt for AI Agents |
||
|
|
||
| def build_extended(self, opts: dict, bus) -> ExtendedAsk: | ||
| """Construct an ExtendedAsk orchestrator with caps from the request.""" | ||
| self.ensure_chat() | ||
| """Construct an ExtendedAsk orchestrator with caps from the request. | ||
|
|
||
| Reads from a single snapshot to avoid mixing old + new components if | ||
| a reload is racing this call. | ||
| """ | ||
| snap = self._state | ||
| return ExtendedAsk( | ||
| self.cfg, self.db, self.vectors, self.embedder, chat=self.chat, bus=bus, | ||
| self.cfg, snap.db, snap.vectors, snap.embedder, chat=snap.chat, bus=bus, | ||
| keyword_rounds=int(opts.get("keyword_rounds", 2)), | ||
| keywords_per_round=int(opts.get("keywords_per_round", 4)), | ||
| agents_per_round=int(opts.get("agents_per_round", 3)), | ||
| max_rounds=int(opts.get("max_rounds", 10)), | ||
| ) | ||
|
|
||
| def reload(self) -> None: | ||
| """Re-open read components (call after an index run completes).""" | ||
| """Re-open read components after an index run. | ||
|
|
||
| Builds the new snapshot OUTSIDE the swap lock (so request threads | ||
| aren't blocked while we load the embedder / chat model), then takes | ||
| the lock just to perform the atomic pointer swap and capture the | ||
| old snapshot. The old DB connection is closed on a delayed daemon | ||
| timer so any in-flight request that already captured it can finish. | ||
| """ | ||
| new_state = self._build() # heavy work, no lock | ||
| with self._reload_lock: # short critical section | ||
| old_state = self._state | ||
| self._state = new_state | ||
| try: | ||
| self.db.close() | ||
| timer = threading.Timer(2.0, lambda: _safe_close(old_state.db)) | ||
| timer.daemon = True # don't block interpreter shutdown | ||
| timer.start() | ||
| except Exception: | ||
| pass | ||
| self._open() | ||
| _safe_close(old_state.db) | ||
|
Comment on lines
+125
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace fixed-delay DB close with usage-aware lifecycle. Line 125 uses a hardcoded 2-second delayed close for 🧰 Tools🪛 Ruff (0.15.15)[warning] 128-128: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _safe_close(db) -> None: | ||
| try: | ||
| db.close() | ||
| except Exception: | ||
| pass | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the inspected WebUI socket paths,
frontend/src/api/client.jsstill connects withtransports: ['websocket', 'polling'], so the browser's first request is a direct/socket.io/?transport=websocket.allow_upgrades=Falseonly removes websocket from the polling handshake's upgrade list; python-engineio still handles an initialtransport == 'websocket'by entering the websocket request path. As a result, the Werkzeug WebSocket 500s this patch is trying to avoid can still occur for the bundled frontend; force polling with the Engine.IO/Socket.IOtransports=['polling']option on the server or update the client transport list.Useful? React with 👍 / 👎.