mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 21:40:23 +01:00
Every protected request spawns a fresh helper subprocess, paying interpreter start and imports before any work begins. Measured inside the container, five consecutive runs of `python -c "import shelfmark.bypass.internal_bypasser"`: ``` 3.53s 3.45s 3.55s 3.54s 3.46s ``` A single search issues several protected requests, so that is paid several times over per search. ## What changed The helper now serves one JSON request per line of stdin until the parent closes the pipe, and an idle timer (`BYPASS_BROWSER_IDLE_TIMEOUT`, default 180s) shuts it down once searching stops. Answers still travel by result file, but the file is now written to a `.part` path and renamed into place — the parent treats the file's existence as the answer, so it must never observe a half-written one. stdout and stderr stay attached to the parent's, so helper logs keep appearing in `docker logs` exactly as before. Failure handling, since a warm helper is exposed to more of it than a per-request one ever was: | Situation | Handling | | --- | --- | | Helper died between requests | Detected via `poll()`, respawned | | Pipe broken at write time (`poll()` can miss this) | One retry on a fresh process; a fresh one failing there is a real failure | | Helper exits without writing a result | `RuntimeError` naming the exit code | | Wedged past the timeout, or cancelled mid-bypass | Helper killed, then `_cleanup_orphan_processes` because a killed helper never got to close Chrome | | Idle reaper racing an arriving request | Re-checks the deadline under the lock and re-arms instead of killing a helper that just did work | The DNS config now travels with every request rather than only at spawn: a warm helper outlives changes the parent makes to its provider. ## `BYPASS_REUSE_BROWSER`, off by default This parks the CDP driver between bypasses. A driver's websockets are bound to the loop that opened them and cannot outlive their process, so the persistent helper is what makes this possible at all — and the warm path runs on `_CDP_WORKER`'s long-lived loop rather than `asyncio.run` for the same reason. The mechanism works. With it on, the browser start disappears from the second request onward: 0.7s from `Reusing warm Chrome browser` to the first bypass attempt, against roughly 16s cold. **It still ships off, because a matched-pair test shows it is a net loss against DDoS-Guard.** Each round primed with one cold bypass, waited 10s, then measured a second — identical timing in both arms, only the browser strategy differing, order balanced (fresh, warm, warm, fresh) so drift over the session cannot masquerade as an effect: | Arm | Measured request | | --- | --- | | fresh browser | 42.8s, 40.6s | | warm browser | 57.1s, 59.6s | Spread within each arm is 2.2s and 2.5s, against 16.7s between them. Reuse removes the ~15s browser start and then gives back roughly twice that in solving: a returning browser draws a harder challenge. Where the cold browser is through on the second bypass method, the warm one fails the first three and only `_bypass_method_humanlike` gets it, at ~30s for that method alone. Worth separating from a second effect I ran into while measuring: five back-to-back searches slow from ~32s to 51–98s with reuse **disabled** as well, so DDoS-Guard escalates on request rate independently of any of this. That is why the pairs above are timed identically rather than simply run in sequence. It is the larger of the two effects, but not something this project can patch around. Reuse is left available rather than dropped because Cloudflare sites may not respond the same way, and because the two concerns are independent: the helper start is pure overhead and always worth removing, the browser is not. ## Verification - 2559 unit tests pass (2542 before, 17 added in `tests/bypass/test_warm_browser.py`) - `ruff check`, `ruff format`, `basedpyright` over backend and tests, and `vulture` all clean - `docs/environment-variables.md` regenerated via `scripts/generate_env_docs.py` - Live against Anna's Archive on a warm helper: searches return their usual ~760KB and 667 results, the app's own search warm-up completes with 50 results, and the container is left with no orphan chrome/Xvfb/ffmpeg processes Happy to drop the `BYPASS_REUSE_BROWSER` half entirely if you would rather not carry a default-off path — the helper persistence stands on its own. Co-authored-by: helgehelge123 <helge.neumann@zollsoft.de>
434 lines
16 KiB
Python
434 lines
16 KiB
Python
"""Tests for keeping the bypass helper process alive between requests.
|
|
|
|
The browser is deliberately not kept: every bypass starts and closes its own Chrome. What
|
|
survives is the helper process, whose interpreter start and imports are pure overhead.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
|
|
|
|
class _FakeStdin:
|
|
def __init__(self) -> None:
|
|
self.closed = False
|
|
self.written: list[str] = []
|
|
|
|
def write(self, data: str) -> None:
|
|
if self.closed:
|
|
raise BrokenPipeError("stdin is closed")
|
|
self.written.append(data)
|
|
|
|
def flush(self) -> None:
|
|
return None
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _FakeProc:
|
|
"""Enough of subprocess.Popen for the helper's process bookkeeping."""
|
|
|
|
_next_pid = 90001
|
|
|
|
def __init__(self) -> None:
|
|
self.stdin = _FakeStdin()
|
|
self.returncode: int | None = None
|
|
# A pid nothing may actually be signalled by: _terminate_helper_session is patched
|
|
# out in these tests, and a stray killpg on a live pid would take out the test run.
|
|
type(self)._next_pid += 1
|
|
self.pid = type(self)._next_pid
|
|
|
|
def poll(self) -> int | None:
|
|
return self.returncode
|
|
|
|
def wait(self, timeout: float | None = None) -> int:
|
|
if self.returncode is None:
|
|
self.returncode = 0
|
|
return self.returncode
|
|
|
|
def kill(self) -> None:
|
|
self.returncode = -9
|
|
|
|
|
|
def _helper_with_fake_spawn(monkeypatch, procs: list[_FakeProc], terminated=None):
|
|
"""Build a helper that hands out fake processes and never arms a real timer."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
def _spawn(_self) -> _FakeProc:
|
|
proc = _FakeProc()
|
|
procs.append(proc)
|
|
return proc
|
|
|
|
def _terminate(proc) -> None:
|
|
if terminated is not None:
|
|
terminated.append(proc)
|
|
|
|
monkeypatch.setattr(internal_bypasser._BypassHelper, "_spawn", _spawn)
|
|
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 0.0)
|
|
monkeypatch.setattr(internal_bypasser, "_terminate_helper_session", _terminate)
|
|
return internal_bypasser._BypassHelper()
|
|
|
|
|
|
def _answered_payload(tmp_path, name: str = "result.json") -> dict:
|
|
"""A request whose result file already exists, so the helper resolves immediately."""
|
|
result_path = tmp_path / name
|
|
result_path.write_text(json.dumps({"ok": True, "html": "<html/>"}), encoding="utf-8")
|
|
return {"url": "https://example.com", "retry": 1, "result_path": str(result_path)}
|
|
|
|
|
|
def test_helper_serves_consecutive_requests_from_one_process(monkeypatch, tmp_path):
|
|
"""The point of the whole thing: request two and three must not re-pay the spawn."""
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
|
|
for i in range(3):
|
|
result = helper.run(_answered_payload(tmp_path, f"r{i}.json"), timeout=5, cancel_flag=None)
|
|
assert result["ok"] is True
|
|
|
|
assert len(procs) == 1, "each request spawned its own helper"
|
|
assert len(procs[0].stdin.written) == 3
|
|
assert all(line.endswith("\n") for line in procs[0].stdin.written), (
|
|
"requests must be newline-delimited or the helper's loop cannot split them"
|
|
)
|
|
|
|
|
|
def test_helper_respawns_after_the_previous_one_died(monkeypatch, tmp_path):
|
|
"""A helper can be reaped while idle; the next request must not fail on it."""
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
|
|
helper.run(_answered_payload(tmp_path, "a.json"), timeout=5, cancel_flag=None)
|
|
procs[0].returncode = 1 # died between requests
|
|
|
|
result = helper.run(_answered_payload(tmp_path, "b.json"), timeout=5, cancel_flag=None)
|
|
|
|
assert result["ok"] is True
|
|
assert len(procs) == 2
|
|
|
|
|
|
def test_helper_retries_once_when_the_pipe_breaks_on_write(monkeypatch, tmp_path):
|
|
"""poll() can still say alive when the far end is already gone."""
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
|
|
helper.run(_answered_payload(tmp_path, "a.json"), timeout=5, cancel_flag=None)
|
|
procs[0].stdin.closed = True # pipe gone, but poll() still reports running
|
|
|
|
result = helper.run(_answered_payload(tmp_path, "b.json"), timeout=5, cancel_flag=None)
|
|
|
|
assert result["ok"] is True
|
|
assert len(procs) == 2
|
|
|
|
|
|
def test_helper_reports_a_helper_that_exits_without_answering(monkeypatch, tmp_path):
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
|
|
payload = {
|
|
"url": "https://example.com",
|
|
"retry": 1,
|
|
"result_path": str(tmp_path / "never-written.json"),
|
|
}
|
|
|
|
def _die_on_write(_self, proc, _line) -> None:
|
|
proc.returncode = 3
|
|
|
|
monkeypatch.setattr(internal_bypasser._BypassHelper, "_write", _die_on_write)
|
|
|
|
with pytest.raises(RuntimeError, match="exited without a result"):
|
|
helper.run(payload, timeout=5, cancel_flag=None)
|
|
|
|
|
|
def test_helper_times_out_and_discards_the_wedged_process(monkeypatch, tmp_path):
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
|
|
payload = {
|
|
"url": "https://example.com",
|
|
"retry": 1,
|
|
"result_path": str(tmp_path / "never-written.json"),
|
|
}
|
|
|
|
with pytest.raises(TimeoutError):
|
|
helper.run(payload, timeout=0.05, cancel_flag=None)
|
|
|
|
assert helper._proc is None, "a wedged helper must not be handed to the next request"
|
|
|
|
|
|
def test_idle_reaper_rearms_when_work_arrived_while_it_waited(monkeypatch, tmp_path):
|
|
"""The timer fires on its own thread and can lose the race against a new request."""
|
|
import time
|
|
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
|
|
|
rearmed: list[bool] = []
|
|
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 3600.0)
|
|
monkeypatch.setattr(
|
|
internal_bypasser._BypassHelper, "_arm_idle_timer", lambda _self: rearmed.append(True)
|
|
)
|
|
helper._last_used = time.monotonic()
|
|
|
|
helper._reap_if_idle()
|
|
|
|
assert rearmed == [True]
|
|
assert helper._proc is not None, "helper was killed despite recent work"
|
|
|
|
|
|
def test_idle_reaper_closes_a_genuinely_idle_helper(monkeypatch, tmp_path):
|
|
import time
|
|
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
|
|
|
monkeypatch.setattr(internal_bypasser._BypassHelper, "_idle_timeout", lambda _self: 60.0)
|
|
helper._last_used = time.monotonic() - 120
|
|
|
|
helper._reap_if_idle()
|
|
|
|
assert helper._proc is None
|
|
assert procs[0].stdin.closed
|
|
|
|
|
|
def test_discard_tears_down_the_whole_session(monkeypatch, tmp_path):
|
|
"""Dropping the helper must reach its browser tree, not just the helper itself.
|
|
|
|
The helper is a session leader (start_new_session), so a Chrome left behind by one
|
|
killed mid-bypass would keep a process group alive that the cleanup sweep is then not
|
|
allowed to reclaim - the leak #1231 was about.
|
|
"""
|
|
procs: list[_FakeProc] = []
|
|
terminated: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs, terminated)
|
|
|
|
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
|
helper._discard()
|
|
|
|
assert terminated == [procs[0]]
|
|
|
|
|
|
def test_helper_asks_before_it_kills(monkeypatch, tmp_path):
|
|
"""An idle helper should get to exit on its own; the kill is the fallback."""
|
|
procs: list[_FakeProc] = []
|
|
helper = _helper_with_fake_spawn(monkeypatch, procs)
|
|
|
|
helper.run(_answered_payload(tmp_path), timeout=5, cancel_flag=None)
|
|
helper._discard()
|
|
|
|
assert procs[0].stdin.closed, "stdin must be closed to end the helper's request loop"
|
|
assert procs[0].returncode == 0, "an idle helper should have exited on its own"
|
|
|
|
|
|
def _bypass_with_recorded_driver(monkeypatch, get_impl):
|
|
"""Wire up a bypass whose browser creation and closing are observable."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
driver = object()
|
|
closed: list[object] = []
|
|
|
|
async def _create(_url):
|
|
return driver
|
|
|
|
async def _close(drv):
|
|
closed.append(drv)
|
|
|
|
monkeypatch.setattr(internal_bypasser, "_create_cdp_browser", _create)
|
|
monkeypatch.setattr(internal_bypasser, "_get", get_impl)
|
|
monkeypatch.setattr(internal_bypasser, "_close_cdp_driver", _close)
|
|
return driver, closed
|
|
|
|
|
|
def test_successful_bypass_closes_its_browser(monkeypatch):
|
|
"""A living helper must not accumulate browsers: each bypass ends with Chrome gone."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
async def _get(_url, _driver, _cancel=None):
|
|
return "<html>ok</html>"
|
|
|
|
driver, closed = _bypass_with_recorded_driver(monkeypatch, _get)
|
|
|
|
result = internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
|
|
|
assert result == "<html>ok</html>"
|
|
assert closed == [driver]
|
|
|
|
|
|
def test_failed_bypass_closes_its_browser(monkeypatch):
|
|
"""The same has to hold when the bypass raises on its way out."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
async def _get(_url, _driver, _cancel=None):
|
|
raise internal_bypasser.BypassCancelledError("cancelled")
|
|
|
|
driver, closed = _bypass_with_recorded_driver(monkeypatch, _get)
|
|
|
|
with pytest.raises(internal_bypasser.BypassCancelledError):
|
|
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
|
|
|
assert closed == [driver]
|
|
|
|
|
|
def test_child_process_serves_every_line_it_is_given(monkeypatch, tmp_path):
|
|
"""One helper, several requests: the loop is what saves the repeated process start."""
|
|
import io
|
|
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
urls: list[str] = []
|
|
|
|
def _fake_get(url, retry=None, cancel_flag=None):
|
|
urls.append(url)
|
|
return f"<html>{url}</html>"
|
|
|
|
requests = [
|
|
{"url": "https://example.com/one", "retry": 1, "result_path": str(tmp_path / "1.json")},
|
|
{"url": "https://example.com/two", "retry": 1, "result_path": str(tmp_path / "2.json")},
|
|
]
|
|
stdin = io.StringIO("\n".join(json.dumps(request) for request in requests) + "\n")
|
|
|
|
monkeypatch.setattr(internal_bypasser, "get", _fake_get)
|
|
monkeypatch.setattr(internal_bypasser.sys, "stdin", stdin)
|
|
|
|
assert internal_bypasser._run_child_process() == 0
|
|
assert urls == ["https://example.com/one", "https://example.com/two"]
|
|
|
|
for index, request in enumerate(requests, start=1):
|
|
result = json.loads((tmp_path / f"{index}.json").read_text(encoding="utf-8"))
|
|
assert result["ok"] is True
|
|
assert result["html"] == f"<html>{request['url']}</html>"
|
|
|
|
|
|
def test_child_process_keeps_serving_after_a_failed_request(monkeypatch, tmp_path):
|
|
"""One failing URL must not take the helper - and everything queued - down."""
|
|
import io
|
|
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
def _fake_get(url, retry=None, cancel_flag=None):
|
|
if url.endswith("boom"):
|
|
raise RuntimeError("bypass exploded")
|
|
return "<html>ok</html>"
|
|
|
|
requests = [
|
|
{"url": "https://example.com/boom", "retry": 1, "result_path": str(tmp_path / "1.json")},
|
|
{"url": "https://example.com/fine", "retry": 1, "result_path": str(tmp_path / "2.json")},
|
|
]
|
|
stdin = io.StringIO("\n".join(json.dumps(request) for request in requests) + "\n")
|
|
|
|
monkeypatch.setattr(internal_bypasser, "get", _fake_get)
|
|
monkeypatch.setattr(internal_bypasser.sys, "stdin", stdin)
|
|
|
|
assert internal_bypasser._run_child_process() == 0
|
|
|
|
failed = json.loads((tmp_path / "1.json").read_text(encoding="utf-8"))
|
|
assert failed["ok"] is False
|
|
assert failed["error"] == "bypass exploded"
|
|
|
|
served = json.loads((tmp_path / "2.json").read_text(encoding="utf-8"))
|
|
assert served["ok"] is True
|
|
|
|
|
|
def test_result_file_becomes_visible_only_when_complete(tmp_path):
|
|
"""The parent treats the file's existence as the answer, so no partial writes."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
result_path = tmp_path / "result.json"
|
|
internal_bypasser._publish_result(result_path, {"ok": True, "html": "<html/>"})
|
|
|
|
assert json.loads(result_path.read_text(encoding="utf-8"))["ok"] is True
|
|
assert list(tmp_path.iterdir()) == [result_path], "temporary file was left behind"
|
|
|
|
|
|
def test_child_bypass_runs_on_the_long_lived_worker_loop(monkeypatch):
|
|
"""A helper serving many requests must not build and close a loop per bypass.
|
|
|
|
asyncio.run() owns the loop for one call and closes it on the way out, which is why the
|
|
child goes through the worker unconditionally: one loop for the process's lifetime.
|
|
"""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
monkeypatch.setenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", "1")
|
|
|
|
loops: list[asyncio.AbstractEventLoop] = []
|
|
|
|
async def _record_loop(_url, _driver, _cancel=None):
|
|
loops.append(asyncio.get_running_loop())
|
|
return "<html>ok</html>"
|
|
|
|
_bypass_with_recorded_driver(monkeypatch, _record_loop)
|
|
|
|
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
|
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
|
|
|
assert len(loops) == 2
|
|
assert loops[0] is loops[1], "second bypass ran on a different loop than the first"
|
|
assert not loops[0].is_closed()
|
|
|
|
|
|
def test_child_bypass_carries_its_own_deadline(monkeypatch):
|
|
"""The child bounds itself, rather than relying only on the parent's deadline."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
monkeypatch.setenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", "1")
|
|
|
|
timeouts: list[float | None] = []
|
|
real_run = internal_bypasser._CDP_WORKER.run
|
|
|
|
def _record_timeout(coro, timeout=None):
|
|
timeouts.append(timeout)
|
|
return real_run(coro, timeout=timeout)
|
|
|
|
async def _get(_url, _driver, _cancel=None):
|
|
return "<html>ok</html>"
|
|
|
|
_bypass_with_recorded_driver(monkeypatch, _get)
|
|
monkeypatch.setattr(internal_bypasser._CDP_WORKER, "run", _record_timeout)
|
|
|
|
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
|
|
|
assert timeouts == [internal_bypasser._CHILD_BYPASS_TIMEOUT_SECONDS]
|
|
|
|
|
|
def test_child_deadline_leaves_the_parent_room_to_hear_the_answer(monkeypatch):
|
|
"""If the parent gave up first it could only kill the helper, losing a warm process."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
assert (
|
|
internal_bypasser._CHILD_BYPASS_TIMEOUT_SECONDS
|
|
< internal_bypasser._BYPASS_SUBPROCESS_TIMEOUT_SECONDS
|
|
)
|
|
|
|
|
|
def test_in_process_bypass_keeps_the_parents_budget(monkeypatch):
|
|
"""Non-Docker installs run in-process, where there is no helper to outlive anything."""
|
|
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
|
|
|
monkeypatch.delenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", raising=False)
|
|
|
|
timeouts: list[float | None] = []
|
|
real_run = internal_bypasser._CDP_WORKER.run
|
|
|
|
def _record_timeout(coro, timeout=None):
|
|
timeouts.append(timeout)
|
|
return real_run(coro, timeout=timeout)
|
|
|
|
async def _get(_url, _driver, _cancel=None):
|
|
return "<html>ok</html>"
|
|
|
|
_bypass_with_recorded_driver(monkeypatch, _get)
|
|
monkeypatch.setattr(internal_bypasser._CDP_WORKER, "run", _record_timeout)
|
|
|
|
internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
|
|
|
assert timeouts == [internal_bypasser._IN_PROCESS_BYPASS_TIMEOUT_SECONDS]
|