From 52acb197a41c59a39c63c4b95f828e58d3818a2b Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Sun, 16 Aug 2026 17:59:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(git=20proxy):=20start/stop=20=E5=8F=AA?= =?UTF-8?q?=E8=AE=A4=E9=A2=86=E5=8C=B9=E9=85=8D=E8=BF=9B=E7=A8=8B(?= =?UTF-8?q?=E9=98=B2=E8=AF=AF=E6=9D=80=E5=90=8C=E7=AB=AF=E5=8F=A3=E5=85=B6?= =?UTF-8?q?=E5=AE=83=E8=BF=9B=E7=A8=8B)=20+=20status/start=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D=E6=8C=87=E5=90=91=E6=AD=BB=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E7=9A=84=20stale=20git=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mytoolkit/commands/git.py | 91 +++++++++++++++++++++++++++++++++++---- tests/test_git_proxy.py | 62 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/mytoolkit/commands/git.py b/mytoolkit/commands/git.py index 026580b..3c354cc 100644 --- a/mytoolkit/commands/git.py +++ b/mytoolkit/commands/git.py @@ -71,6 +71,18 @@ def _set_git_proxies(proxy_url: str) -> None: ) +def _restore_proxy_key(key: str, old: object) -> None: + """Restore a git proxy key to its pre-daemon value (or unset it).""" + if old in (None, "(not set)"): + _unset_git_proxy(key) + else: + subprocess.run( + ["git", "config", "--global", key, str(old)], + capture_output=True, + check=False, + ) + + def _proxy_loopback_port(proxy_url: str) -> int | None: """Return listen port if proxy_url is a loopback HTTP proxy.""" try: @@ -108,6 +120,27 @@ def _parallels_host_ips() -> list[str]: return ips +def _procs_matching(pids: list[int], name: str) -> list[int]: + """Filter PIDs whose command line mentions the proxy name. + + Guards against adopting/killing an unrelated process that happens to bind + the same loopback port (e.g. a leftover socat or another proxy tool). + """ + matched: list[int] = [] + for pid in pids: + try: + out = subprocess.check_output( + ["ps", "-p", str(pid), "-o", "command="], + text=True, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.CalledProcessError): + continue + if name.lower() in out.strip().lower(): + matched.append(pid) + return matched + + def _port_listening_on(host: str, port: int) -> bool: return bool(_pids_listening_on(host, port)) @@ -318,6 +351,32 @@ def _clear_state() -> None: pass +def _restore_stale_git_proxy() -> bool: + """Restore git proxy if it points at a dead loopback port. + + A session that ran `start --set-git` / `run` and exited without `stop` + leaves the global git proxy pointing at the daemon port; if the daemon then + died, every git call fails behind a dead proxy. Restore the pre-proxy value + recorded in the state file (or unset). Returns True if anything changed. + """ + state = _read_state() + if not state: + return False + http = _get_git_proxy("http.proxy") + if not http: + return False + port = _proxy_loopback_port(http) + if port is None or _port_listening_on("127.0.0.1", port): + return False + _restore_proxy_key("http.proxy", state.get("old_http")) + _restore_proxy_key("https.proxy", state.get("old_https")) + click.secho( + f"Restored stale git proxy (http.proxy pointed at dead {http})", + fg="green", + ) + return True + + def _require_proxy(name: str) -> tuple[str, str]: """Return (name, url) or exit.""" proxies = _get_proxies() @@ -411,6 +470,8 @@ def _collect_forward_pids(port: int) -> list[int]: def _start_daemon(name: str, *, set_git: bool) -> None: + # Clear a proxy left pointing at a dead daemon by an earlier aborted run. + _restore_stale_git_proxy() _, proxy_url = _require_proxy(name) port = _proxy_loopback_port(proxy_url) @@ -443,11 +504,19 @@ def _start_daemon(name: str, *, set_git: bool) -> None: return if _port_listening_on("127.0.0.1", port): - click.secho( - f"{name} already listening on 127.0.0.1:{port}", fg="cyan" - ) - pids = _pids_listening_on("127.0.0.1", port) - proxy_pid = pids[0] if pids else None + pids = _procs_matching(_pids_listening_on("127.0.0.1", port), name) + if pids: + click.secho( + f"{name} already listening on 127.0.0.1:{port} (pid={pids[0]})", + fg="cyan", + ) + proxy_pid = pids[0] + else: + click.secho( + f"WARN: 127.0.0.1:{port} is held by an unknown process — " + f"not adopting it (stop will not touch it)", + fg="yellow", + ) else: binary = shutil.which(name) if not binary: @@ -525,7 +594,7 @@ def _stop_daemon(name: str | None) -> None: if port is not None: forward_pids = _collect_forward_pids(port) - if state_matches: + if state_matches and state is not None: for pid in state.get("forward_pids") or []: if isinstance(pid, int) and pid not in forward_pids: forward_pids.append(pid) @@ -534,17 +603,19 @@ def _stop_daemon(name: str | None) -> None: else: click.secho(f"{name} socat: nothing listening on guest IPs", fg="yellow") - proxy_pids = _pids_listening_on("127.0.0.1", port) + proxy_pids = _procs_matching(_pids_listening_on("127.0.0.1", port), name) if proxy_pids: _kill_pids(proxy_pids, name) else: click.secho( - f"{name} is not listening on 127.0.0.1:{port}", fg="yellow" + f"{name} is not listening on 127.0.0.1:{port} " + "(or port is held by an unrelated process — left untouched)", + fg="yellow", ) else: click.secho(f"{name}: no local loopback daemon to stop", fg="yellow") - if state_matches and state.get("set_git"): + if state_matches and state is not None and state.get("set_git"): old_http = state.get("old_http") old_https = state.get("old_https") if old_http in (None, "(not set)"): @@ -672,6 +743,8 @@ def proxy_status(name: str | None) -> None: current = _current_daemon_name() state = _read_state() + _restore_stale_git_proxy() + click.secho("Git proxy daemons:", fg="cyan") if not proxies: click.echo(" (none configured in ~/.mytoolkit/config.json)") diff --git a/tests/test_git_proxy.py b/tests/test_git_proxy.py index 071908e..ba3db8f 100644 --- a/tests/test_git_proxy.py +++ b/tests/test_git_proxy.py @@ -11,3 +11,65 @@ def test_proxy_loopback_port_http(): def test_proxy_loopback_port_rejects_remote(): assert _proxy_loopback_port("http://10.211.55.2:38457") is None assert _proxy_loopback_port("socks5://127.0.0.1:1080") is None + + +def test_procs_matching_filters_by_command(): + from mytoolkit.commands.git import _procs_matching + import os + + pid = os.getpid() + # Current test process command line contains "python". + assert pid in _procs_matching([pid], "python") + assert _procs_matching([pid], "definitely-not-a-real-proc-xyz") == [] + + +def test_restore_stale_git_proxy_restores_when_port_dead(monkeypatch): + from mytoolkit.commands.git import _restore_stale_git_proxy + + restored: dict[str, object] = {} + monkeypatch.setattr( + "mytoolkit.commands.git._read_state", + lambda: {"old_http": "http://old.example:1", "old_https": None}, + ) + monkeypatch.setattr( + "mytoolkit.commands.git._get_git_proxy", + lambda key: "http://127.0.0.1:59999", + ) + monkeypatch.setattr( + "mytoolkit.commands.git._proxy_loopback_port", lambda url: 59999 + ) + monkeypatch.setattr( + "mytoolkit.commands.git._port_listening_on", lambda host, port: False + ) + monkeypatch.setattr( + "mytoolkit.commands.git._restore_proxy_key", + lambda key, old: restored.__setitem__(key, old), + ) + assert _restore_stale_git_proxy() is True + assert restored == {"http.proxy": "http://old.example:1", "https.proxy": None} + + +def test_restore_stale_git_proxy_noop_when_alive(monkeypatch): + from mytoolkit.commands.git import _restore_stale_git_proxy + + restored: dict[str, object] = {} + monkeypatch.setattr( + "mytoolkit.commands.git._read_state", + lambda: {"old_http": "http://old.example:1"}, + ) + monkeypatch.setattr( + "mytoolkit.commands.git._get_git_proxy", + lambda key: "http://127.0.0.1:59999", + ) + monkeypatch.setattr( + "mytoolkit.commands.git._proxy_loopback_port", lambda url: 59999 + ) + monkeypatch.setattr( + "mytoolkit.commands.git._port_listening_on", lambda host, port: True + ) + monkeypatch.setattr( + "mytoolkit.commands.git._restore_proxy_key", + lambda key, old: restored.__setitem__(key, old), + ) + assert _restore_stale_git_proxy() is False + assert restored == {}