diff --git a/CHANGELOG.md b/CHANGELOG.md index 109a838..75783c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The format follows Keep a Changelog and semantic versioning. +## [3.0.4] — 2026-09-28 + +### Fixed + +- Scans with optional speed or anonymity checks now save failed target checks immediately. On networks that accept TCP connections but do not answer requests, progress, results, observations, and job items no longer stay at zero or pending. + ## [3.0.3] — 2026-09-27 ### Fixed diff --git a/README.md b/README.md index 46b5960..6aef2b5 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,9 @@ Choose the build for your system. The [latest release](https://github.com/DavidV | System | Install or run | Requirements | | --- | --- | --- | -| **macOS, Apple Silicon** | Download `proxy-workbench-3.0.3-macos-arm64.dmg`; drag the app into Applications. | No Python needed | -| **macOS, Intel** | Download `proxy-workbench-3.0.3-macos-x86_64.dmg`; drag the app into Applications. | No Python needed | -| **Windows x64** | Run `proxy-workbench-3.0.3-windows-x64-setup.exe`; a portable ZIP and separate CLI `.exe` are also available. | No Python needed | +| **macOS, Apple Silicon** | Download `proxy-workbench-3.0.4-macos-arm64.dmg`; drag the app into Applications. | No Python needed | +| **macOS, Intel** | Download `proxy-workbench-3.0.4-macos-x86_64.dmg`; drag the app into Applications. | No Python needed | +| **Windows x64** | Run `proxy-workbench-3.0.4-windows-x64-setup.exe`; a portable ZIP and separate CLI `.exe` are also available. | No Python needed | | **Linux, or any OS with Python** | `pipx install git+https://github.com/DavidVoitenko/proxy-workbench` then `proxy-workbench` | Python 3.11+ and [pipx](https://pypa.io/pipx/) | | **Source checkout** | `Start.bat` on Windows, `Start.command` on macOS, or `./run.sh` on Linux. | Python 3.11+ | | **Docker server/NAS** | Set `PROXY_WORKBENCH_API_TOKEN` and run `docker compose up -d` with [`compose.yml`](compose.yml). | Docker; headless CLI/API/gateway, no GUI | diff --git a/README.ru.md b/README.ru.md index e6bd8f2..a910e7b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -83,9 +83,9 @@ | Система | Установка или запуск | Что нужно | | --- | --- | --- | -| **macOS, Apple Silicon** | Скачайте `proxy-workbench-3.0.3-macos-arm64.dmg` и перетащите приложение в «Программы». | Python не нужен | -| **macOS, Intel** | Скачайте `proxy-workbench-3.0.3-macos-x86_64.dmg` и перетащите приложение в «Программы». | Python не нужен | -| **Windows x64** | Запустите `proxy-workbench-3.0.3-windows-x64-setup.exe`. Есть portable ZIP и отдельный CLI `.exe`. | Python не нужен | +| **macOS, Apple Silicon** | Скачайте `proxy-workbench-3.0.4-macos-arm64.dmg` и перетащите приложение в «Программы». | Python не нужен | +| **macOS, Intel** | Скачайте `proxy-workbench-3.0.4-macos-x86_64.dmg` и перетащите приложение в «Программы». | Python не нужен | +| **Windows x64** | Запустите `proxy-workbench-3.0.4-windows-x64-setup.exe`. Есть portable ZIP и отдельный CLI `.exe`. | Python не нужен | | **Linux или любая ОС с Python** | `pipx install git+https://github.com/DavidVoitenko/proxy-workbench`, затем `proxy-workbench`. | Python 3.11+ и [pipx](https://pypa.io/pipx/) | | **Папка с исходниками** | `Start.bat` на Windows, `Start.command` на macOS или `./run.sh` на Linux. | Python 3.11+ | | **Сервер/NAS с Docker** | Задайте `PROXY_WORKBENCH_API_TOKEN` и выполните `docker compose up -d` с готовым [`compose.yml`](compose.yml). | Docker; CLI/API/шлюз без GUI | diff --git a/packaging/windows-installer.iss b/packaging/windows-installer.iss index ccf8dd7..053fa63 100644 --- a/packaging/windows-installer.iss +++ b/packaging/windows-installer.iss @@ -1,6 +1,6 @@ ; Per-user installer for the Windows desktop build. ; -; iscc /DProductVersion=3.0.3 /DOutDir=C:\path\to\dist /DSourceDir=C:\path\to\dist packaging\windows-installer.iss +; iscc /DProductVersion=3.0.4 /DOutDir=C:\path\to\dist /DSourceDir=C:\path\to\dist packaging\windows-installer.iss ; ; PrivilegesRequired=lowest is the whole point: the app writes to per-user ; folders, so it never needs an administrator, and it never installs anything diff --git a/proxy_workbench/branding.py b/proxy_workbench/branding.py index 5ac61d9..84a0eee 100644 --- a/proxy_workbench/branding.py +++ b/proxy_workbench/branding.py @@ -11,7 +11,7 @@ PRODUCT_NAME = "Proxy Workbench" PRODUCT_ID = "ProxyWorkbench" -PRODUCT_VERSION = "3.0.3" +PRODUCT_VERSION = "3.0.4" DEFAULT_REQUEST_PROFILE = "workbench" PROJECT_URL = "https://github.com/DavidVoitenko/proxy-workbench" # Newest built-in source list, fetched only when the user asks for it. This is diff --git a/proxy_workbench/proxytool.py b/proxy_workbench/proxytool.py index e173ab2..cc831b4 100644 --- a/proxy_workbench/proxytool.py +++ b/proxy_workbench/proxytool.py @@ -4675,7 +4675,9 @@ async def basic_stage(item, *, stage, limit): row['reputation'] = verdict rows[proxy] = row outcome, ok = _verdict(stage, row, final=expensive_probe is None) - if expensive_probe is None: + # A failed basic check never reaches the expensive stage. Persist its + # verdict here even when judge/speed probes are configured. + if not outcome.ok or expensive_probe is None: finish(proxy, ok) return outcome diff --git a/pyproject.toml b/pyproject.toml index 82badc9..650a54a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "proxy-workbench" # Must equal proxy_workbench.branding.PRODUCT_VERSION: the binary advertised one # number and the wheel another. -version = "3.0.3" +version = "3.0.4" description = "Collect free public proxies and keep only the ones that work for your services: local GUI + CLI checker for HTTP, HTTPS and SOCKS5" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_scan_engine_regressions.py b/tests/test_scan_engine_regressions.py index a4e59d9..e9cbc95 100644 --- a/tests/test_scan_engine_regressions.py +++ b/tests/test_scan_engine_regressions.py @@ -14,6 +14,7 @@ import contextlib import errno import io +import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import socket @@ -41,6 +42,29 @@ def do_GET(self): self.wfile.write(body) +class _BlackholeAfterConnect(BaseHTTPRequestHandler): + """Accept TCP, but leave the target or speed response unanswered.""" + + def handle(self): + try: + super().handle() + except (ConnectionResetError, BrokenPipeError): + pass # The TCP prefilter closes as soon as connect succeeds. + + def log_message(self, *args): + pass + + def do_GET(self): + if self.server.basic_works and self.path.endswith('/health'): + body = b'healthy' + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + return + time.sleep(6) + + def _closed_port(): with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) @@ -48,6 +72,49 @@ def _closed_port(): class CliScanJobTests(unittest.TestCase): + def test_speed_scan_records_blackholed_proxies_and_speed_timeout(self): + servers = [] + for index in range(8): + server = ThreadingHTTPServer(('127.0.0.1', 0), _BlackholeAfterConnect) + server.basic_works = index == 0 + threading.Thread(target=server.serve_forever, daemon=True).start() + servers.append(server) + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + + with tempfile.TemporaryDirectory() as tmp: + data = Path(tmp) + listing = data / 'list.txt' + listing.write_text('\n'.join(f'http://127.0.0.1:{server.server_port}' + for server in servers) + '\n') + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out), \ + mock.patch.dict('os.environ', {'HTTP_PROXY': '', 'HTTPS_PROXY': '', 'ALL_PROXY': ''}): + self.assertEqual(proxytool.main(['collect', '--no-sources', '--data', str(data), + '--input', str(listing), '--allow-private-endpoints']), 0, + out.getvalue()) + code = proxytool.main(['scan', '--data', str(data), + '--url', 'http://service.invalid/health', '--attempts', '1', + '--timeout', '2', '--connect-timeout', '1', + '--workers', '8', '--max-per-host', '8', '--rate', '0', + '--prefilter', '8', '--prefilter-timeout', '1', + '--speedtest-url', 'http://speed.invalid/file', + '--speedtest-bytes', '2000000', '--deadline', '15']) + self.assertEqual(code, 0, out.getvalue()) + self.assertIn('Checked 8/8', out.getvalue()) + with contextlib.closing(sqlite3.connect(data / 'proxies.sqlite3')) as conn: + job_id = conn.execute('SELECT id FROM job ORDER BY created_at DESC LIMIT 1').fetchone()[0] + results = [json.loads(payload) for (payload,) in conn.execute( + 'SELECT payload FROM results WHERE job_id=?', (job_id,))] + observations = conn.execute('SELECT COUNT(*) FROM observations WHERE job_id=?', + (job_id,)).fetchone()[0] + pending = conn.execute("SELECT COUNT(*) FROM job_item WHERE job_id=? AND state='pending'", + (job_id,)).fetchone()[0] + self.assertEqual((len(results), observations, pending), (8, 8, 0)) + self.assertEqual(sum(row['successes'] > 0 for row in results), 1, + (out.getvalue(), results)) + self.assertEqual(sum(row.get('speed', {}).get('state') == 'error' for row in results), 1) + def test_a_cli_scan_finishes_every_job_item_without_waiting_on_itself(self): server = ThreadingHTTPServer(('127.0.0.1', 0), _Healthy) threading.Thread(target=server.serve_forever, daemon=True).start() diff --git a/tests/test_scan_integration_regressions.py b/tests/test_scan_integration_regressions.py index 01ec9f2..e1fdc15 100644 --- a/tests/test_scan_integration_regressions.py +++ b/tests/test_scan_integration_regressions.py @@ -12,7 +12,7 @@ from dataclasses import replace from unittest import mock -from proxy_workbench import core, db, pipeline, probes, proxytool as p +from proxy_workbench import core, db, jobs, pipeline, probes, proxytool as p from tests.workbench_support import add_candidate, store_result @@ -215,6 +215,38 @@ async def unreachable(proxy, timeout): self.assertEqual(state['stop_reason'], 'complete') self.assertEqual(self.calls, []) + async def test_failed_basic_checks_are_saved_when_speed_test_is_enabled(self): + proxies = self.seed(8) + cfg = copy.deepcopy(CONFIG) + cfg['speedtest'] = {'url': 'http://speed.invalid/file', 'max_bytes': 2_000_000} + store = jobs.JobStore(self.conn) + profile = 'speed-failure-regression' + endpoints = [self.conn.execute('SELECT endpoint_id FROM candidates WHERE proxy=?', + (proxy,)).fetchone()[0] for proxy in proxies] + job = store.submit('check', jobs.Scope(db.PUBLIC_COLLECTION_ID, profile, 1), + [jobs.QueueItem(endpoint) for endpoint in endpoints]) + store.start(job.id) + speed_calls = [] + + async def failed_probe(proxy, config, rate): + return p.summarize(proxy, [dict(ok=False, status=None, ms=10, bytes=0, + error='TimeoutError', target=0, attempt=1)], config) + + async def expensive_probe(proxy, config, rate, row): + speed_calls.append(proxy) + return row + + state = await self.scan(cfg, probe=failed_probe, expensive_probe=expensive_probe, + workers=4, profile_id=profile, job_id=job.id, job_store=store) + self.assertEqual(speed_calls, [], 'a failed target must not trigger a speed download') + self.assertEqual((state['checked'], state['pending']), (8, 0)) + self.assertEqual(len(self.saved()), 8) + self.assertTrue(all(row['reliability'] == 0 for row in self.saved())) + self.assertEqual(self.conn.execute('SELECT COUNT(*) FROM observations WHERE job_id=?', + (job.id,)).fetchone()[0], 8) + self.assertTrue(all(item.state == 'done' and item.observation_id + for item in store.items(job.id))) + async def test_httpx_mock_transport_also_obeys_request_budget(self): import httpx self.seed()