zapretgui/tests/test_update_core_fetch.py
loop-uh 59e1580961
All checks were successful
Zapret source guards / validate (push) Successful in 9s
Windows-сборка / Dev Dev 21.1.7.208 опубликован
Update download: drop a silent source in seconds, show "waiting for the server"
On the 21.1.7.207 rollout Forgejo answered each store file in 30+ seconds,
the client read timeout was 30 s, and the update window froze on
"0 KB/s, 9 min". The server now serves store files from an nginx cache;
this commit fixes the client side.

zapret_update/fetch.py (shared core, CORE_SHA256 recomputed):
- STALL_TIMEOUT = 10 s: a source that sends nothing before the first byte
  or between pieces is dropped (was 30 s);
- one ranking shared by all files and threads: a silent or failing source
  goes to the back for everyone, so a stuck server costs one wait per
  thread, not one per file;
- read1 instead of read: bytes already received are written before a
  stall, so the next source resumes with Range, and progress moves evenly.

Update window: UpdateFlow recounts progress every second while downloading;
after 5 s without new bytes it says "Ждём ответа сервера…" and drops the
stale time estimate; speeds under 10 KB/s get a decimal instead of "0".

Tests: a silent source with 16 files (at most one wait per thread, done in
seconds), a source that stops mid-file (resumed by Range on the next one),
the waiting text, the 1 s tick; the new tests fail on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:48:10 -04:00

275 lines
12 KiB
Python
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.

from __future__ import annotations
"""Общее ядро: план, скачивание из хранилища с докачкой и пробное дерево (Python 3.14)."""
import hashlib
import http.server
import random
import threading
import time
from pathlib import Path
import pytest
pytest.importorskip("compression.zstd", reason="сжатие zstd есть только в Python 3.14")
from zapret_update import builder, fetch, manifest, plan, stage, store # noqa: E402
class _Store(http.server.ThreadingHTTPServer):
daemon_threads = True
def __init__(
self,
files: dict[str, bytes],
*,
cut_after: int | None = None,
corrupt: bool = False,
hang: bool = False,
hang_after: int | None = None,
) -> None:
self.files, self.cut_after, self.corrupt, self.requests = files, cut_after, corrupt, []
# hang — принимает запрос и молчит; hang_after — отдаёт столько байт тела и молчит.
self.hang, self.hang_after, self.released = hang, hang_after, threading.Event()
super().__init__(("127.0.0.1", 0), _Handler)
threading.Thread(target=self.serve_forever, daemon=True).start()
def shutdown(self) -> None:
self.released.set()
super().shutdown()
def url_for(self, name: str) -> str:
return f"http://127.0.0.1:{self.server_address[1]}/{name}"
class _Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_args) -> None:
pass
def do_GET(self) -> None: # noqa: N802
server: _Store = self.server
name = self.path.lstrip("/")
server.requests.append((name, self.headers.get("Range")))
if server.hang:
server.released.wait(30)
return
data = server.files.get(name)
if data is None:
self.send_response(404)
self.end_headers()
return
if server.corrupt:
data = bytes(len(data))
start = 0
if self.headers.get("Range"):
start = int(self.headers["Range"].split("=")[1].rstrip("-"))
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{len(data) - 1}/{len(data)}")
else:
self.send_response(200)
body = data[start:]
if server.cut_after is not None:
body = body[: server.cut_after]
server.cut_after = None # обрыв только один раз
self.send_header("Content-Length", str(len(data) - start))
self.end_headers()
try:
if server.hang_after is not None:
self.wfile.write(body[: server.hang_after])
self.wfile.flush()
server.released.wait(30)
return
self.wfile.write(body)
except OSError:
pass
def _release(tmp_path: Path):
"""Установленная версия 1 и выпуск 2: один файл изменился, один новый, один ушёл."""
rng = random.Random(7)
exe_old = rng.randbytes(600_000)
exe_new = exe_old[:300_000] + b"fix" * 50 + exe_old[300_000:]
v1 = {"_internal/Zapret.exe": exe_old, "lua/a.lua": b"print(1)", "lua/old.lua": b"old"}
v2 = {"_internal/Zapret.exe": exe_new, "lua/a.lua": b"print(1)", "lua/new.lua": b"new"}
root = tmp_path / "app"
builds = {}
for version, files in (("1.0.0.1", v1), ("1.0.0.2", v2)):
source = tmp_path / f"src{version}"
for relative, data in files.items():
(source / relative).parent.mkdir(parents=True, exist_ok=True)
(source / relative).write_bytes(data)
previous = [builds["1.0.0.1"]] if builds else []
built = builder.build_store(
layout={relative: source / relative for relative in files}, cache_dir=tmp_path / "cache", previous=previous
)
builds[version] = manifest.parse({
"schema": 1, "product": "zapret-gui", "channel": "dev", "version": version, "created": 1, "key": "k",
"files": built.files, "patches": built.patches,
})
for relative, data in v1.items():
(root / relative).parent.mkdir(parents=True, exist_ok=True)
(root / relative).write_bytes(data)
stored = {path.name: path.read_bytes() for path in (tmp_path / "cache").glob("*.zst")}
return root, builds["1.0.0.1"], builds["1.0.0.2"], stored, v2
def test_plan_keeps_same_files_patches_changed_and_removes_gone(tmp_path) -> None:
root, old, new, _stored, _v2 = _release(tmp_path)
planned = plan.make_plan(new, root, installed=old)
assert [entry.path for entry in planned.keep] == ["lua/a.lua"]
by_path = {want.entry.path: want for want in planned.want}
assert by_path["_internal/Zapret.exe"].patch is not None
assert by_path["lua/new.lua"].patch is None
assert planned.remove == ["lua/old.lua"]
assert planned.download_size < 20_000 # вместо ~600 КБ файла целиком
def test_plan_without_knowing_the_installed_version_removes_nothing(tmp_path) -> None:
root, _old, new, _stored, _v2 = _release(tmp_path)
assert plan.make_plan(new, root).remove == []
def test_version_without_store_cannot_be_planned_by_files(tmp_path) -> None:
root, _old, new, _stored, _v2 = _release(tmp_path)
bare = manifest.parse({
"schema": 1, "product": "zapret-gui", "channel": "dev", "version": "1.0.0.3", "created": 1, "key": "k",
"files": [{"path": "a", "size": 1, "sha256": "a" * 64}],
})
with pytest.raises(plan.PlanError):
plan.make_plan(bare, root)
def test_fetch_resumes_after_a_cut_and_builds_the_new_tree(tmp_path) -> None:
root, old, new, stored, v2 = _release(tmp_path)
server = _Store(stored, cut_after=3)
planned = plan.make_plan(new, root, installed=old)
seen = []
ready = fetch.fetch_plan(
planned, root=root, work_dir=tmp_path / "work", threads=2,
sources=[fetch.Source("forgejo", server.url_for)], progress=lambda done, total: seen.append((done, total)),
sleep=lambda _s: None,
)
tree = stage.build_stage(planned, root=root, ready=ready, stage_dir=tmp_path / "stage")
assert {path.relative_to(tree).as_posix(): path.read_bytes() for path in tree.rglob("*") if path.is_file()} == v2
# Оборванный кусок продолжен с места обрыва, а не заново.
assert any(rng and rng != "bytes=0-" for _name, rng in server.requests)
assert seen[-1] == (planned.download_size, planned.download_size)
server.shutdown()
def test_broken_source_is_skipped_for_the_next_one(tmp_path) -> None:
root, old, new, stored, v2 = _release(tmp_path)
bad, good = _Store(stored, corrupt=True), _Store(stored)
planned = plan.make_plan(new, root, installed=old)
ready = fetch.fetch_plan(
planned, root=root, work_dir=tmp_path / "work",
sources=[fetch.Source("bad", bad.url_for), fetch.Source("good", good.url_for)], sleep=lambda _s: None,
)
assert all(hashlib.sha256(path.read_bytes()).hexdigest() == sha for sha, path in ready.items())
bad.shutdown(), good.shutdown()
def test_nothing_anywhere_gives_a_network_reason(tmp_path) -> None:
root, old, new, _stored, _v2 = _release(tmp_path)
empty = _Store({})
planned = plan.make_plan(new, root, installed=old)
with pytest.raises(fetch.FetchError) as caught:
fetch.fetch_plan(planned, root=root, work_dir=tmp_path / "work", sources=[fetch.Source("e", empty.url_for)], sleep=lambda _s: None)
assert caught.value.code == "net"
empty.shutdown()
def test_already_downloaded_files_are_not_fetched_again(tmp_path) -> None:
root, old, new, stored, _v2 = _release(tmp_path)
server = _Store(stored)
planned = plan.make_plan(new, root, installed=old)
kwargs = dict(root=root, work_dir=tmp_path / "work", sources=[fetch.Source("s", server.url_for)], sleep=lambda _s: None)
fetch.fetch_plan(planned, **kwargs)
server.requests.clear()
fetch.fetch_plan(planned, **kwargs)
assert server.requests == []
server.shutdown()
def test_stage_refuses_a_program_file_changed_during_the_update(tmp_path) -> None:
root, old, new, stored, _v2 = _release(tmp_path)
server = _Store(stored)
planned = plan.make_plan(new, root, installed=old)
ready = fetch.fetch_plan(planned, root=root, work_dir=tmp_path / "w", sources=[fetch.Source("s", server.url_for)], sleep=lambda _s: None)
(root / "lua" / "a.lua").write_bytes(b"print(2)") # тот же размер, другое содержимое
with pytest.raises(stage.StageError):
stage.build_stage(planned, root=root, ready=ready, stage_dir=tmp_path / "stage")
server.shutdown()
def _many_new_files(tmp_path: Path, count: int):
"""Выпуск из ``count`` новых файлов: каждый качается отдельным запросом."""
rng = random.Random(11)
files = {f"lua/f{index}.lua": rng.randbytes(3_000) for index in range(count)}
source = tmp_path / "src"
for relative, data in files.items():
(source / relative).parent.mkdir(parents=True, exist_ok=True)
(source / relative).write_bytes(data)
built = builder.build_store(layout={relative: source / relative for relative in files}, cache_dir=tmp_path / "cache")
release = manifest.parse({
"schema": 1, "product": "zapret-gui", "channel": "dev", "version": "1.0.0.2", "created": 1, "key": "k",
"files": built.files, "patches": built.patches,
})
root = tmp_path / "app"
root.mkdir()
stored = {path.name: path.read_bytes() for path in (tmp_path / "cache").glob("*.zst")}
return root, release, stored
def test_a_silent_source_is_dropped_for_all_files_after_one_wait(tmp_path) -> None:
# Выход 21.1.7.207: перегруженный Forgejo молчал 30+ секунд на каждый файл,
# и каждый файл ждал его до обрыва. Зависший источник должен стоить одно
# ожидание на поток, а не по ожиданию на файл.
root, release, stored = _many_new_files(tmp_path, 16)
silent, good = _Store(stored, hang=True), _Store(stored)
planned = plan.make_plan(release, root)
started = time.monotonic()
ready = fetch.fetch_plan(
planned, root=root, work_dir=tmp_path / "work", threads=4, timeout=0.5,
sources=[fetch.Source("silent", silent.url_for), fetch.Source("good", good.url_for)], sleep=lambda _s: None,
)
assert len(ready) == 16
assert len(silent.requests) <= 4
assert time.monotonic() - started < 4.0
silent.shutdown(), good.shutdown()
def test_a_source_that_stops_mid_file_is_resumed_on_the_next_one(tmp_path) -> None:
root, old, new, stored, v2 = _release(tmp_path)
stuck, good = _Store(stored, hang_after=5), _Store(stored)
planned = plan.make_plan(new, root, installed=old)
ready = fetch.fetch_plan(
planned, root=root, work_dir=tmp_path / "work", threads=2, timeout=0.5,
sources=[fetch.Source("stuck", stuck.url_for), fetch.Source("good", good.url_for)], sleep=lambda _s: None,
)
tree = stage.build_stage(planned, root=root, ready=ready, stage_dir=tmp_path / "stage")
assert {path.relative_to(tree).as_posix(): path.read_bytes() for path in tree.rglob("*") if path.is_file()} == v2
# Следующий источник продолжил с места, где замолчал первый.
assert any(rng == "bytes=5-" for _name, rng in good.requests)
stuck.shutdown(), good.shutdown()
def test_default_wait_for_a_silent_source_is_short() -> None:
assert fetch.STALL_TIMEOUT <= 10.0