mirror of
https://github.com/sabnzbd/sabnzbd.git
synced 2026-09-14 22:51:34 -04:00
* Migrate web interface from CherryPy to Uvicorn/Starlette
Squashed rebase of feature/uvicorn (34 commits) onto develop, reconciled
with ~3 months of intervening develop changes.
Replaces the CherryPy webserver and request handling with Uvicorn/Starlette
across the API, web interface, RSS, config pages and related modules.
Reconciliation with develop during the rebase:
- api.py: kept develop's security/behaviour fixes (orphan path-traversal
guard, expanded log redaction incl. host_whitelist and
remote_label_replacement, get_dconfig single-return, get_retryable_jobs,
connections default, translated NNTP test errors) on top of the Starlette
request/response rewrite.
- interface.py: ported the RSS route handlers to develop's DB-backed
RSSRepository API (process_feed, rss_repository / find_job_by_url /
clear_feed / clear_downloaded / flag_downloaded).
- misc.py: kept develop's hachoir-based get_media_duration.
- requirements.txt: dropped the CherryPy stack, adopted develop's newer pins.
Also applied ruff --fix (PEP 604 unions, builtin generics) to align with
develop's lint config.
Verified: ruff check, black --check, and the affected test suites
(9413 passed, 1 skipped) all pass.
* Update starlette/uvicorn versions
* Fix race issues in global rss state
* Fix test race in server shutdown
The uvicorn migration turned /shutdown (and the shutdown API) into fire-and-forget: it spawned shutdown_program() in a background thread and replied immediately, whereas develop ran it synchronously and only replied once halt() had persisted all state. Because the module-scoped test teardown doesn't wait for the process to exit, the next module's clean_cache_dir wiped the shared cache dir (and reused the fixed port) while the previous instance was still saving state and holding the port — producing the three intermittent failures (deleted sabnzbd.log → "File log disabled or not found"; un-persisted [sorters] → KeyError; stale instance → missing wizard .quoteBlock).
* Fix robots and description, add favicon
* Remove remains of http basic auth
* Setup Starlette once configuration is available, fix static file relative cwd and url_base config
* abort_and_show_error when webserver fails to start
* Guard stopping webserver that never started
* Delegate XFF handling to ProxyHeadersMiddleware
* Merged params at request.state.params instead of modifying private apis
* Both shutdown routes share implementation and do not block event loop
* Run sync handlers via run_in_threadpool and facilitate eventual migration to async
* Pool database connections
* Online backup of database due to WAL changes
* Fix exception on None request.client (test clients or unix sockets)
* Fix flakey tests due to process not fully shutting down
* Restore X-Frame-Options behaviour via middleware
* Fix set_config_default with multiple keywords
* Remove broken logging call
* Restore api logging functionality
* Cache-Control: no-store
* Login only via POST
* Remove 401 (basic-auth) and add 404 handling via redirect
* Fix crash when shutdown not an int
* Use BaseRedirectResponse helper
* Remove trailing slashes from wizard routes
* URL helper, absolute URLs everywhere, fixes issues with nested navigation
* Fix scheduler adding multiple daysofweek
* Restore CherryPy api behaviour merging body with query params (body wins)
* Clearer documentation of get_request_params and request_params
* First stage supporting gradual api async
* Fix rss ajax consuming flash
* Restore access log functionality
* Hostname check in middleware
* Request logging in middleware
* Param parsing in middleware
* Security checks in middleware
* secured_expose is now purely route registration
* Lookup api handler once per request
* Fix flakey alert dialogs
* Trigger restart via BackgroundTask
* Restore CherryPy first param wins and get/post consistency
* Remove dead code
* Secure cookies based on protocol the client used
* Fix various issues with port_is_free
1. port_is_free answered the wrong question. It connect-probed ("is something answering?") rather than bind-probed ("can I bind?"). A port could report free and then kill startup at uvicorn's bind().
2. The bind-all remap crossed address families. :: was mapped to 127.0.0.1, probing IPv4 for an IPv6 bind — a regression against portend, which maps :: → ::1.
3. The call sites passed the wrong host. browserhost is a client-reachable address; the thing that has to be bindable is web_host.
4. Errors were swallowed. A bare except OSError hid gaierror, so an unresolvable host reported "free".
5. find_free_port had a port-0 trap. Under a bind-probe, currentport=0 always succeeds and returned 0 — the old failure sentinel. Now guarded, and None instead of 0.
6. Ports 80/443 were misdiagnosed. EACCES was folded into "occupied", producing ten futile probes and a panic claiming another program held the port. PermissionError now propagates to a dedicated panic explaining the actual remedies.
7. The tests were largely tautological. Three tests covering one branch, an IPv6 test with no IPv6 in it, a timeout test that never engaged the timeout, TOCTOU-prone fixed-range probes, no SO_REUSEADDR on the helper listener, and nothing asserting the property that matters — that "free" implies bindable.
8. A portability bug I introduced, then fixed. I'd baked Linux SO_REUSEADDR overlap semantics into four assertions; macOS differs. Now platform-aware, with the IPv6 regression re-covered by checking the socket family directly.
* Claim the bind address for uvicorn on startup, resolves "49" in err handling from cherrypy
* Rename function BaseRedirectResponse to base_redirect_response
* Restore error response on change web directory
* Add missing typings
* Fix return type of retry job for future types
* A better fix for xdist compatibility - test overwrote db_path
* Secure session cookies (rss flash)
* Inline or remove some functions
* Retry job futuretype behaviour
* Sneak a worksteal fix in
* Test and fix retry_job futuretype behaviour
169 lines
6.4 KiB
Python
169 lines
6.4 KiB
Python
#!/usr/bin/python3 -OO
|
|
# Copyright 2007-2026 by The SABnzbd-Team (sabnzbd.org)
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
|
|
"""
|
|
tests.test_database - Testing the HistoryDB connection pool
|
|
"""
|
|
|
|
import sqlite3
|
|
import threading
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
import sabnzbd
|
|
import sabnzbd.database as db
|
|
|
|
|
|
@pytest.fixture
|
|
def pool(tmp_path, monkeypatch):
|
|
"""Fresh pool against a fresh database file"""
|
|
monkeypatch.setattr(db.HistoryDB, "db_path", str(tmp_path / "history1.db"))
|
|
monkeypatch.setattr(db.HistoryDB, "startup_done", False)
|
|
pool = db.HistoryDBPool(max_connections=2, checkout_timeout=0.2)
|
|
yield pool
|
|
pool.close()
|
|
|
|
|
|
class TestHistoryDBPool:
|
|
def test_lifo_reuse(self, pool):
|
|
with pool.connection() as first:
|
|
assert first.execute("SELECT COUNT(*) FROM history")
|
|
with pool.connection() as second:
|
|
# The returned connection is reused, not a new one
|
|
assert second is first
|
|
assert pool._created == 1
|
|
|
|
def test_exclusive_checkout_and_bounded(self, pool):
|
|
with pool.connection() as first, pool.connection() as second:
|
|
assert first is not second
|
|
assert pool._created == 2
|
|
# Third concurrent checkout exceeds max_connections: served by a
|
|
# temporary overflow connection instead of blocking forever
|
|
with pool.connection() as third:
|
|
assert third is not first
|
|
assert third is not second
|
|
assert pool._created == 2
|
|
# Overflow connection was closed on check-in, not pooled
|
|
assert pool._idle.qsize() == 0
|
|
|
|
def test_returned_connections_reused_when_full(self, pool):
|
|
with pool.connection() as first:
|
|
with pool.connection() as second:
|
|
pass
|
|
with pool.connection() as reused:
|
|
assert reused in (first, second)
|
|
assert pool._created == 2
|
|
|
|
def test_invalidate_discards_pooled_connections(self, pool):
|
|
with pool.connection() as first:
|
|
pass
|
|
pool.invalidate()
|
|
with pool.connection() as second:
|
|
assert second is not first
|
|
# Still works after replacement
|
|
assert second.execute("SELECT COUNT(*) FROM history")
|
|
assert pool._created == 1
|
|
|
|
def test_invalidate_spares_reconnected_instance(self, pool):
|
|
with pool.connection() as first:
|
|
pool.invalidate(reconnected=first)
|
|
# The reconnected instance survives and is pooled again
|
|
with pool.connection() as second:
|
|
assert second is first
|
|
|
|
def test_close_discards_and_serves_overflow(self, pool):
|
|
with pool.connection() as first:
|
|
pass
|
|
pool.close()
|
|
assert pool._idle.qsize() == 0
|
|
# Late checkout after shutdown still works, via a temporary connection
|
|
with pool.connection() as late:
|
|
assert late is not first
|
|
assert late.execute("SELECT COUNT(*) FROM history")
|
|
assert pool._idle.qsize() == 0
|
|
|
|
def test_connection_usable_across_threads(self, pool):
|
|
"""Pooled connections move between (e.g. AnyIO worker) threads"""
|
|
with pool.connection() as history_db:
|
|
pass
|
|
|
|
result = {}
|
|
|
|
def worker():
|
|
with pool.connection() as history_db2:
|
|
result["same"] = history_db2 is history_db
|
|
history_db2.execute("SELECT COUNT(*) FROM history")
|
|
result["count"] = history_db2.cursor.fetchone()[0]
|
|
|
|
thread = threading.Thread(target=worker)
|
|
thread.start()
|
|
thread.join()
|
|
|
|
assert result["same"] is True
|
|
assert result["count"] == 0
|
|
|
|
def test_wal_mode_enabled(self, pool):
|
|
with pool.connection() as history_db:
|
|
history_db.execute("PRAGMA journal_mode;")
|
|
assert history_db.cursor.fetchone()[0] == "wal"
|
|
|
|
|
|
class TestHistoryDBSnapshot:
|
|
@staticmethod
|
|
def _count_history_rows(db_file: str) -> int:
|
|
connection = sqlite3.connect(db_file)
|
|
try:
|
|
return connection.execute("SELECT COUNT(*) FROM history").fetchone()[0]
|
|
except sqlite3.Error:
|
|
# Empty or inconsistent database
|
|
return 0
|
|
finally:
|
|
connection.close()
|
|
|
|
def test_snapshot_includes_uncheckpointed_wal_data(self, pool, tmp_path, monkeypatch):
|
|
"""The snapshot must contain transactions still in the WAL, which a
|
|
raw copy of the main database file misses"""
|
|
monkeypatch.setattr(sabnzbd, "db_pool", pool)
|
|
with pool.connection() as history_db:
|
|
assert history_db.execute(
|
|
"INSERT INTO history (completed, name, nzb_name) VALUES (?, ?, ?)", (123, "job", "job.nzb")
|
|
)
|
|
|
|
# Snapshot sees the row
|
|
snapshot = db.history_db_snapshot()
|
|
assert snapshot
|
|
snapshot_file = str(tmp_path / "snapshot.db")
|
|
with open(snapshot_file, "wb") as snapshot_ref:
|
|
snapshot_ref.write(snapshot)
|
|
assert self._count_history_rows(snapshot_file) == 1
|
|
|
|
# While the row is not in the main database file yet: it is in the WAL
|
|
raw_file = str(tmp_path / "raw_copy.db")
|
|
with open(db.HistoryDB.db_path, "rb") as source_ref, open(raw_file, "wb") as raw_ref:
|
|
raw_ref.write(source_ref.read())
|
|
assert self._count_history_rows(raw_file) == 0
|
|
|
|
def test_snapshot_failure_returns_none(self, pool, monkeypatch):
|
|
monkeypatch.setattr(sabnzbd, "db_pool", pool)
|
|
with pool.connection() as history_db:
|
|
pass
|
|
# sqlite3.Connection attributes are read-only, so replace the HistoryDB
|
|
# instance attribute with a mock whose backup() fails
|
|
monkeypatch.setattr(history_db, "connection", mock.Mock(backup=mock.Mock(side_effect=sqlite3.Error)))
|
|
assert db.history_db_snapshot() is None
|