Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 3 additions & 3 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packaging/windows-installer.iss
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion proxy_workbench/branding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion proxy_workbench/proxytool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions tests/test_scan_engine_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import contextlib
import errno
import io
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import socket
Expand Down Expand Up @@ -41,13 +42,79 @@ 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))
return probe.getsockname()[1]


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()
Expand Down
34 changes: 33 additions & 1 deletion tests/test_scan_integration_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down
Loading