diff --git a/packages/node/meshnet_node/cli.py b/packages/node/meshnet_node/cli.py index a874176..cd35ca3 100644 --- a/packages/node/meshnet_node/cli.py +++ b/packages/node/meshnet_node/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import socket import sys import time from pathlib import Path @@ -49,6 +50,19 @@ def _run_node(cfg: dict) -> None: ) +def _first_available_port(host: str, start: int = 7000, attempts: int = 100) -> int: + """Return the first TCP port bindable on host, starting at start.""" + bind_host = "" if host == "0.0.0.0" else host + for port in range(start, start + attempts): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind((bind_host, port)) + except OSError: + continue + return port + raise OSError(f"no available TCP port in range {start}-{start + attempts - 1}") + + def _cmd_default(args) -> int: """No subcommand: wizard if no config, else start with saved config.""" from .config import load_config, save_config, merge_cli_overrides @@ -145,7 +159,7 @@ def _cmd_start(args) -> int: # Build a transient config from flags (don't write to disk) cfg = dict(DEFAULTS) cfg["tracker_url"] = args.tracker - cfg["port"] = args.port + cfg["port"] = args.port if args.port is not None else _first_available_port(args.host) if args.model_id is None and "/" in args.model: cfg["model_hf_repo"] = args.model cfg["model_name"] = args.model.split("/")[-1] @@ -236,7 +250,7 @@ def main() -> None: # start subcommand (legacy / backward-compat) start_cmd = subparsers.add_parser("start", help="Start node (legacy flags)") start_cmd.add_argument("--tracker", default="http://localhost:8080") - start_cmd.add_argument("--port", type=int, default=7000) + start_cmd.add_argument("--port", type=int) start_cmd.add_argument("--model", default="stub-model") start_cmd.add_argument("--model-id", help="HuggingFace repo ID") start_cmd.add_argument("--shard-start", type=int) diff --git a/tests/test_mining_cli.py b/tests/test_mining_cli.py index cc553c2..e26331f 100644 --- a/tests/test_mining_cli.py +++ b/tests/test_mining_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import socket import sys import types from pathlib import Path @@ -387,6 +388,43 @@ def test_legacy_start_treats_repo_model_as_model_id(monkeypatch): assert captured["model_id"] == "Qwen/Qwen2.5-0.5B-Instruct" +def test_legacy_start_without_port_uses_next_available_port(monkeypatch): + """Omitting --port skips an occupied default port before startup loads the model.""" + from meshnet_node.cli import main + + captured = {} + + def fake_run_startup(*args, **kwargs): + captured.update(kwargs) + class _FakeNode: + chat_completion_count = 0 + def stop(self): pass + return _FakeNode() + + occupied = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + occupied.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + occupied.bind(("127.0.0.1", 7000)) + occupied.listen(1) + try: + monkeypatch.setattr(sys, "argv", [ + "meshnet-node", "start", + "--tracker", "http://192.168.0.179:8081", + "--model", "Qwen/Qwen2.5-0.5B-Instruct", + "--host", "127.0.0.1", + ]) + + with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup): + with patch("time.sleep", side_effect=KeyboardInterrupt): + try: + main() + except SystemExit as exc: + assert exc.code == 0 + finally: + occupied.close() + + assert captured["port"] == 7001 + + def test_default_cli_passes_advertise_host(monkeypatch): """The documented no-subcommand LAN flag reaches startup.""" from meshnet_node.cli import main