From e104534cd73013ffefa03cf3f2e8fdfcd5f24dec Mon Sep 17 00:00:00 2001 From: Anonymous Date: Mon, 28 Sep 2026 10:52:44 +0300 Subject: [PATCH 1/3] Record failed checks when speed testing is enabled --- proxy_workbench/proxytool.py | 4 ++- tests/test_scan_integration_regressions.py | 34 +++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) 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/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() From 28b4d8ad1aabbf67cd700e3c4ff8028d95b0a528 Mon Sep 17 00:00:00 2001 From: Anonymous Date: Mon, 28 Sep 2026 11:56:29 +0300 Subject: [PATCH 2/3] Verify blackholed scans and prepare 3.0.4 release --- CHANGELOG.md | 6 +++ README.md | 6 +-- README.ru.md | 6 +-- packaging/windows-installer.iss | 2 +- proxy_workbench/branding.py | 2 +- pyproject.toml | 2 +- tests/test_scan_engine_regressions.py | 66 +++++++++++++++++++++++++++ 7 files changed, 81 insertions(+), 9 deletions(-) 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/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..77c4277 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 'service.invalid/health' in self.path: + 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(1.5) + + def _closed_port(): with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) @@ -48,6 +72,48 @@ 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', '0.2', '--connect-timeout', '0.2', + '--workers', '8', '--max-per-host', '8', '--rate', '0', + '--prefilter', '8', '--prefilter-timeout', '0.2', + '--speedtest-url', 'http://speed.invalid/file', + '--speedtest-bytes', '2000000', '--deadline', '5']) + 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) + 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() From c2ed372b858f78a4603b8046baaac041fcaa97cd Mon Sep 17 00:00:00 2001 From: Anonymous Date: Mon, 28 Sep 2026 12:02:22 +0300 Subject: [PATCH 3/3] Make blackhole regression reliable on Windows --- tests/test_scan_engine_regressions.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_scan_engine_regressions.py b/tests/test_scan_engine_regressions.py index 77c4277..e9cbc95 100644 --- a/tests/test_scan_engine_regressions.py +++ b/tests/test_scan_engine_regressions.py @@ -55,14 +55,14 @@ def log_message(self, *args): pass def do_GET(self): - if self.server.basic_works and 'service.invalid/health' in self.path: + 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(1.5) + time.sleep(6) def _closed_port(): @@ -95,11 +95,11 @@ def test_speed_scan_records_blackholed_proxies_and_speed_timeout(self): out.getvalue()) code = proxytool.main(['scan', '--data', str(data), '--url', 'http://service.invalid/health', '--attempts', '1', - '--timeout', '0.2', '--connect-timeout', '0.2', + '--timeout', '2', '--connect-timeout', '1', '--workers', '8', '--max-per-host', '8', '--rate', '0', - '--prefilter', '8', '--prefilter-timeout', '0.2', + '--prefilter', '8', '--prefilter-timeout', '1', '--speedtest-url', 'http://speed.invalid/file', - '--speedtest-bytes', '2000000', '--deadline', '5']) + '--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: @@ -111,7 +111,8 @@ def test_speed_scan_records_blackholed_proxies_and_speed_timeout(self): 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) + 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):