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
42 changes: 39 additions & 3 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ env:
BENCH_BASE_DIR: ${{ github.workspace }}/.ci/benchmark_base
BENCH_HEAD_DIR: ${{ github.workspace }}/.ci/benchmark_head
BENCH_COMPARE_DIR: ${{ github.workspace }}/docs/en/reports/benchmarks/generated
BENCH_CONFIG_FILE: docs/examples/example-config-performance.toml
# Canonical examples live under docs/en/examples/ (docs/examples is legacy on older branches).
BENCH_CONFIG_FILE: docs/en/examples/example-config-performance.toml
BENCH_QUICK: "1"
BENCH_KEEP_HISTORY: "20"

Expand Down Expand Up @@ -58,10 +59,14 @@ jobs:
if [ "${BENCH_QUICK}" = "1" ]; then
QUICK_ARG="--quick"
fi
CONFIG_FILE="${BENCH_CONFIG_FILE}"
if [ ! -f "${CONFIG_FILE}" ] && [ -f "docs/examples/example-config-performance.toml" ]; then
CONFIG_FILE="docs/examples/example-config-performance.toml"
fi
uv run python dev/scripts/run_benchmark_suite.py \
--output-dir "${BENCH_HEAD_DIR}" \
--workdir "${{ github.workspace }}" \
--config-file "${BENCH_CONFIG_FILE}" \
--config-file "${CONFIG_FILE}" \
--record-mode none \
--runner python \
${QUICK_ARG}
Expand All @@ -84,11 +89,42 @@ jobs:
if [ "${BENCH_QUICK}" = "1" ]; then
QUICK_ARG="--quick"
fi
# Older main required daemon.api_key but shipped ccbt.toml with it commented out.
# Inject a disposable key so base checkout can load Config during comparison runs.
python - <<'PY'
from pathlib import Path
import re
path = Path("${{ env.BENCH_BASE_DIR }}") / "ccbt.toml"
if not path.is_file():
raise SystemExit(0)
text = path.read_text(encoding="utf-8")
daemon = re.search(r"(?ms)^\[daemon\](.*?)(?=^\[|\Z)", text)
if daemon and re.search(r"(?m)^\s*api_key\s*=", daemon.group(1)):
raise SystemExit(0)
if "[daemon]" in text:
text = text.replace(
"[daemon]",
'[daemon]\napi_key = "ci-benchmark-base-key"',
1,
)
else:
text += '\n[daemon]\napi_key = "ci-benchmark-base-key"\n'
path.write_text(text, encoding="utf-8")
print("Injected daemon.api_key into base ccbt.toml for benchmark compatibility")
PY
# Prefer en/ path; fall back to legacy docs/examples on older base trees.
CONFIG_FILE="${BENCH_CONFIG_FILE}"
if [ ! -f "${BENCH_BASE_DIR}/${CONFIG_FILE}" ] && [ -f "${BENCH_BASE_DIR}/docs/examples/example-config-performance.toml" ]; then
CONFIG_FILE="docs/examples/example-config-performance.toml"
fi
if [ ! -f "${CONFIG_FILE}" ] && [ -f "docs/en/examples/example-config-performance.toml" ]; then
CONFIG_FILE="docs/en/examples/example-config-performance.toml"
fi
(cd "${BENCH_BASE_DIR}" && uv sync --dev)
uv run python dev/scripts/run_benchmark_suite.py \
--output-dir "${BENCH_BASE_DIR}" \
--workdir "${BENCH_BASE_DIR}" \
--config-file "${BENCH_CONFIG_FILE}" \
--config-file "${CONFIG_FILE}" \
--record-mode none \
--runner uv \
${QUICK_ARG}
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

The canonical changelog is maintained in [`dev/CHANGELOG.md`](dev/CHANGELOG.md).

## [0.1.0] - 2026-07-23

See [`dev/CHANGELOG.md`](dev/CHANGELOG.md) for the full 0.1.0 release notes.

## [0.0.1] - 2024-12-XX

Initial release. See [`dev/CHANGELOG.md`](dev/CHANGELOG.md).
2 changes: 1 addition & 1 deletion ccbt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import importlib

__version__ = "0.0.1"
__version__ = "0.1.0"

# Ensure a default asyncio event loop exists on import for libraries/tests that
# construct futures outside of a running loop (e.g., asyncio.Future()).
Expand Down
14 changes: 6 additions & 8 deletions ccbt/peer/async_peer_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7256,8 +7256,7 @@ async def connect_with_timeout(
async with self.connection_lock:
duplicate = (
peer_key in self.connections
or peer_key
in self._connection_reservations
or peer_key in self._connection_reservations
)
self.logger.debug(
"Skipping connect to %s: duplicate or per-torrent capacity reserved",
Expand Down Expand Up @@ -7489,9 +7488,7 @@ async def indexed_connect(
# Connection batch: process with timeout and early exit if enough connections succeed
async def _process_completed_batch(
task_list: list[
asyncio.Task[
tuple[int, ConnectAttemptOutcome]
]
asyncio.Task[tuple[int, ConnectAttemptOutcome]]
],
batch_peer_list: list[PeerInfo],
results_list: list[Any],
Expand Down Expand Up @@ -7732,7 +7729,9 @@ async def _process_completed_batch(
f"Batch timeout after {batch_timeout}s"
),
)
_register_aborted_batch_peer(task_peers[i])
_register_aborted_batch_peer(
task_peers[i]
)
completed_count += 1
await self._release_cancelled_connect_tasks(
tasks,
Expand Down Expand Up @@ -14304,8 +14303,7 @@ async def _monitor_unchoke_timeout(
)
pending_replacements = len(self._pending_peer_queue)
replacement_pressure = (
active_for_solo >= choked_reserve_floor
and pending_replacements > 0
active_for_solo >= choked_reserve_floor and pending_replacements > 0
) or active_for_solo >= self.max_peers_per_torrent
_bdl = int(getattr(connection.stats, "bytes_downloaded", 0) or 0)
_out = len(getattr(connection, "outstanding_requests", {}) or {})
Expand Down
18 changes: 9 additions & 9 deletions ccbt/piece/async_piece_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4468,9 +4468,7 @@ def peer_has_confirmed_piece(peer: AsyncPeerConnection) -> bool:
else list(available_peers)
)
active_peer_count = len(active_peers)
requestable_peer_count = sum(
1 for peer in active_peers if peer.can_request()
)
requestable_peer_count = sum(1 for peer in active_peers if peer.can_request())
pipeline_utilization_limit = (
1.0
if is_stale_for_pipeline_relaxation
Expand Down Expand Up @@ -5445,12 +5443,14 @@ async def handle_piece_block(
for i, (req_begin, req_length, _) in enumerate(requests):
if req_begin == begin and req_length == block_length:
requests.pop(i)
self._piece_selection_metrics[
"active_block_requests"
] = max(
0,
self._piece_selection_metrics["active_block_requests"]
- 1,
self._piece_selection_metrics["active_block_requests"] = (
max(
0,
self._piece_selection_metrics[
"active_block_requests"
]
- 1,
)
)
break
if not requests:
Expand Down
4 changes: 1 addition & 3 deletions ccbt/session/peers.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,9 +891,7 @@ async def connect_peers_to_download(self, peer_list: list[dict[str, Any]]) -> An
self.session._queued_peers.append(peer) # noqa: SLF001
queued = getattr(self.session, "get_queued_peers", None)
queued_count = (
len(queued())
if callable(queued)
else len(self.session._queued_peers) # noqa: SLF001
len(queued()) if callable(queued) else len(self.session._queued_peers) # noqa: SLF001
)
self.session.logger.debug(
"Queued %d peer(s) for later connection (total queued: %d)",
Expand Down
33 changes: 32 additions & 1 deletion ccbt/session/torrent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ def _log_conversion_failure_rate_limited(
logger.debug("Could not convert torrent_data to TorrentInfo (key=%s)", key)


def _normalize_announce_list_for_model(
announce_list: Any,
) -> Optional[list[list[str]]]:
"""Normalize flat or tiered announce lists to BEP 12 ``list[list[str]]``.

Magnet parsing and tracker merge helpers store a flat ``list[str]`` on
``torrent_data``; ``TorrentInfo`` requires tiered announce lists.
"""
if announce_list is None:
return None
if not isinstance(announce_list, list) or not announce_list:
return None

# Flat list[str] from magnet/merge_tracker_urls_into_torrent_data.
if all(isinstance(entry, str) for entry in announce_list):
return [[url] for url in announce_list if url]

normalized: list[list[str]] = []
for tier in announce_list:
if isinstance(tier, str):
if tier:
normalized.append([tier])
elif isinstance(tier, list):
urls = [url for url in tier if isinstance(url, str) and url]
if urls:
normalized.append(urls)
return normalized or None


def get_torrent_info(
torrent_data: Union[dict[str, Any], TorrentInfoModel],
logger: Optional[Any] = None,
Expand Down Expand Up @@ -118,7 +147,9 @@ def get_torrent_info(
info_hash=info_hash,
swarm_id=torrent_data.get("swarm_id"),
announce=torrent_data.get("announce", ""),
announce_list=torrent_data.get("announce_list"),
announce_list=_normalize_announce_list_for_model(
torrent_data.get("announce_list")
),
is_private=torrent_data.get("is_private", False),
files=file_info_list,
total_length=torrent_data.get("total_length", 0),
Expand Down
3 changes: 2 additions & 1 deletion ccbt/utils/port_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ def is_port_listening(
timeout: float = 0.5,
) -> bool:
"""Return True when a TCP listener accepts connections on host:port."""
connect_host = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
# Compare against wildcard bind addresses; this is not a bind call. # nosec B104
connect_host = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host # nosec B104
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout)
Expand Down
8 changes: 7 additions & 1 deletion dev/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.0] - 2026-07-23

### Breaking Changes

- Remove top-level ``btbt config-extended``; extended subcommands now live under ``btbt config`` such as ``config schema`` and ``config import`` (Joseph Pollack, ccBitTorrent contributors)
Expand All @@ -20,14 +22,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Validate ``config set`` with config simulation and JSON/comma-list value parsing before writes (Joseph Pollack, ccBitTorrent contributors)
- Add ``config import --mode merge|replace`` for partial and full-document imports (Joseph Pollack, ccBitTorrent contributors)
- Add recursive config option discovery and shared env/CLI list-field parsing constants (Joseph Pollack, ccBitTorrent contributors)
- Add swarm-health / cold-start connection and pipeline scheduling improvements for sparse swarms (Joseph Pollack, ccBitTorrent contributors)

### Changed

- Defer session tracker metadata fallback while peer connection batches are active to reduce duplicate metadata churn before TCP settles (Joseph Pollack, ccBitTorrent contributors)
- Treat BitTorrent peer streams as non-reusable live sockets with exact lease ownership in the connection pool (Joseph Pollack, ccBitTorrent contributors)

### Internal

- Pre-commit: Ruff, ty, Bandit, and compatibility-linter fixes across discovery, MSE, session, SSL, and peer code (Joseph Pollack, ccBitTorrent contributors)
- Align CI tests and fixtures with current peer, scrape, magnet, and config surfaces (Joseph Pollack, ccBitTorrent contributors)

### Fixed 🐞

Expand Down Expand Up @@ -72,4 +77,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Session refactoring with controller-based architecture and dependency injection (Joseph Pollack, ccBitTorrent contributors)
- Improved tracker, peer, and piece stability checks and async typing/type cleanup for pre-commit readiness (Joseph Pollack, ccBitTorrent contributors)

[0.0.1]: https://github.com/ccBittorrent/ccbt/releases/tag/v0.0.1
[0.1.0]: https://github.com/ccBitTorrent/ccbt/releases/tag/v0.1.0
[0.0.1]: https://github.com/ccBitTorrent/ccbt/releases/tag/v0.0.1
9 changes: 9 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ CCBT_MSE_INITIATOR_TIMEOUT_SCALE_ZERO_ACTIVE=1.0
CCBT_MAX_PEERS=200 # Maximum global peers (1-10000)
CCBT_MAX_PEERS_PER_TORRENT=50 # Maximum peers per torrent (1-1000)
CCBT_MAX_UPLOAD_SLOTS=4 # Maximum upload slots (1-20); raising can help reciprocal UNCHOKE from strict peers
CCBT_LOW_SWARM_MIN_UPLOAD_SLOTS=8 # Minimum upload slots when swarm is small and leech-heavy (1-20)
CCBT_CONNECT_BATCH_EARLY_EXIT_MIN_ACTIVE_PEERS=10 # Do not early-cancel in-flight connects until this many post-handshake actives (1-50)
CCBT_CONNECT_BATCH_ZERO_ACTIVE_MAX_DURATION_S=60.0 # Wall-clock budget for a connect batch when active peers are zero (30-120)
CCBT_MAX_LIVE_SOCKETS=200 # Process-wide maximum live inbound and outbound peer sockets (1-10000)
# Tit-for-tat / reciprocation: prioritize upload to peers who choke us but have data we want; widen our unchoke when few remotes feed us
CCBT_RECIPROCATION_CHOKED_PEER_SCORE_BOOST=0.12 # Extra upload-slot score (0-0.5) when peer_choking and we are interested
CCBT_RECIPROCATION_REMOTE_NOT_INTERESTED_BOOST=0.06 # Extra score when remote not interested yet but we need their pieces
Expand All @@ -104,6 +108,10 @@ CCBT_PEER_CHOKED_ANCHOR_TIMEOUT_SECONDS=75.0 # Seed-anchor peers get this longe
CCBT_PEER_CHOKED_SOLO_GRACE_SECONDS=180.0 # Min grace when you are alone or nobody is requestable yet (30-3600); lowering rotates peers faster but risks zero connections
CCBT_PEER_CHOKED_SOLO_GRACE_ZERO_BYTES_CAP_SECONDS=0 # If >0 and zero bytes/outstanding, cap solo grace at this (0=disabled)
CCBT_METADATA_EXCHANGE_TIMEOUT=60.0 # Metadata exchange timeout in seconds (BEP 9 compliant)
CCBT_METADATA_EXCHANGE_MAX_PEERS=10 # Max parallel peers for metadata exchange after cold start (1-50)
CCBT_METADATA_EXCHANGE_COLD_START_MAX_PEERS=18 # Parallel peers for metadata during magnet cold start (1-30)
CCBT_METADATA_EXCHANGE_COLD_START_TIMEOUT=15.0 # Per-fetch timeout (seconds) for metadata during magnet cold start
CCBT_METADATA_PHASE_PLAINTEXT_CONNECT_ATTEMPTS=1 # Outbound connects to skip MSE while metadata incomplete and no actives (0-5)
CCBT_METADATA_PIECE_TIMEOUT=15.0 # Timeout per metadata piece request in seconds
# Post-handshake wait for bitfield/HAVE (see network.bitfield_have_wait_* in models)
CCBT_BITFIELD_HAVE_WAIT_TIMEOUT_S=120.0
Expand All @@ -114,6 +122,7 @@ CCBT_PER_PEER_UP_KIB=0 # Per-peer upload limit (0+)
CCBT_PIPELINE_ADAPTIVE_DEPTH=true # Enable adaptive pipeline depth based on connection latency
CCBT_PIPELINE_COALESCE_THRESHOLD_KIB=4 # Maximum gap in KiB for coalescing adjacent requests
CCBT_PIPELINE_DEPTH=16 # Request pipeline depth (1-128)
CCBT_REQUEST_TIMEOUT=60.0 # Base BitTorrent block request timeout in seconds (1.0-600.0)
CCBT_SPARSE_PIPELINE_STALE_PAYLOAD_CANCEL_S=120 # 0=off: when ≤1 requestable peer and pipeline ~full, cancel oldest requests after no piece bytes this long (seconds)
CCBT_PIPELINE_ENABLE_COALESCING=true # Enable request coalescing (combine adjacent requests)
CCBT_PIPELINE_ENABLE_PRIORITIZATION=true # Enable request prioritization (rarest pieces first)
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "ccbt"
version = "0.0.1"
version = "0.1.0"
description = "The Easiest , Smallest & Fastest High-performance BitTorrent Client With All The Features"
readme = "dev/README_PyPI.md"
requires-python = ">=3.9"
Expand Down Expand Up @@ -284,7 +284,7 @@ skips = ["B101", "B601"]
# Commitizen configuration
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.0.1"
version = "0.1.0"
tag_format = "v$version"
version_scheme = "pep440"

Expand Down
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,20 @@ def create_mock_config():
config.limits.global_up_kib = 0
config.network = MagicMock()
config.network.max_global_peers = 100
config.network.max_peers_per_torrent = 50
config.network.connection_timeout = 30.0
config.network.handshake_timeout = 10.0
config.network.enable_tcp = True
config.network.enable_utp = False
config.network.listen_port = 6881
config.network.listen_port_tcp = 6881
config.network.listen_port_udp = 6881
config.network.tracker_udp_port = 6882
config.network.xet_multicast_address = "239.255.255.250"
config.network.xet_multicast_port = 6882
config.discovery.max_tracker_urls_per_torrent = 7
config.xet_sync = MagicMock()
config.xet_sync.enable_xet = False
return config


Expand Down
12 changes: 9 additions & 3 deletions tests/daemon/test_ipc_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,20 @@ def test_ipc_client_http_headers(self):
assert API_KEY_HEADER in headers
assert headers[API_KEY_HEADER] == api_key

def test_ipc_client_no_api_key(self):
"""Test that IPCClient handles missing API key gracefully."""
def test_ipc_client_no_api_key(self, monkeypatch: pytest.MonkeyPatch):
"""Test that IPCClient omits auth header when no key is available."""
# Constructor resolves from daemon config when api_key is None; isolate that.
monkeypatch.setattr(
"ccbt.daemon.daemon_manager.resolve_daemon_connection_params",
lambda: (8080, None, None),
)
client = IPCClient(api_key=None)
client.api_key = None

headers = client._get_headers()

# Should return empty headers if no API key
assert headers == {}
assert API_KEY_HEADER not in headers

def test_ipc_client_websocket_url(self):
"""Test that IPCClient includes API key in WebSocket URL."""
Expand Down
Loading
Loading