From b69873f30be40a11a59f9707994e1a89878ce3cf Mon Sep 17 00:00:00 2001 From: kigland Date: Sun, 16 Aug 2026 13:32:09 +0800 Subject: [PATCH 1/2] fix Linux daemon port lookup --- .../hindsight_embed/daemon_embed_manager.py | 33 ++++++++++++++----- hindsight-embed/tests/test_embed_manager.py | 23 +++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/hindsight-embed/hindsight_embed/daemon_embed_manager.py b/hindsight-embed/hindsight_embed/daemon_embed_manager.py index 65397fde00..d008e25e8e 100644 --- a/hindsight-embed/hindsight_embed/daemon_embed_manager.py +++ b/hindsight-embed/hindsight_embed/daemon_embed_manager.py @@ -327,10 +327,8 @@ def _is_port_in_use(port: int) -> bool: @staticmethod def _find_pid_on_port(port: int) -> int | None: """Find the PID of the process listening on a port.""" - import platform - - try: - if platform.system() == "Windows": + if platform.system() == "Windows": + try: # Use netstat on Windows create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) result = subprocess.run( @@ -344,8 +342,11 @@ def _find_pid_on_port(port: int) -> int | None: for line in result.stdout.splitlines(): if f"127.0.0.1:{port}" in line and "LISTENING" in line: return int(line.strip().split()[-1]) - else: - # Use lsof on macOS/Linux + except (subprocess.TimeoutExpired, ValueError, OSError): + return None + else: + try: + # lsof is available by default on macOS and common on Linux. result = subprocess.run( ["lsof", "-ti", f":{port}", "-sTCP:LISTEN"], capture_output=True, @@ -354,8 +355,24 @@ def _find_pid_on_port(port: int) -> int | None: ) if result.returncode == 0 and result.stdout.strip(): return int(result.stdout.strip().split()[0]) - except (subprocess.TimeoutExpired, ValueError, OSError, FileNotFoundError): - pass + except (subprocess.TimeoutExpired, ValueError, OSError): + pass + + if platform.system() == "Linux": + try: + # Minimal Linux installations may provide ss but not lsof. + result = subprocess.run( + ["ss", "-H", "-ltnp", f"sport = :{port}"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + match = re.search(r"\bpid=(\d+)\b", result.stdout) + if match: + return int(match.group(1)) + except (subprocess.TimeoutExpired, ValueError, OSError): + pass return None @staticmethod diff --git a/hindsight-embed/tests/test_embed_manager.py b/hindsight-embed/tests/test_embed_manager.py index 0be1034961..0267b461d9 100644 --- a/hindsight-embed/tests/test_embed_manager.py +++ b/hindsight-embed/tests/test_embed_manager.py @@ -384,6 +384,29 @@ def fake_run(*args, **kwargs): assert calls[0][1]["creationflags"] == 0x08000000 +def test_find_pid_on_port_linux_falls_back_to_ss(monkeypatch): + calls = [] + + def fake_run(command, **kwargs): + calls.append(command) + if command[0] == "lsof": + raise FileNotFoundError + + result = MagicMock() + result.returncode = 0 + result.stdout = 'LISTEN 0 4096 127.0.0.1:9177 0.0.0.0:* users:(("hindsight-api",pid=15774,fd=19))\n' + return result + + monkeypatch.setattr("hindsight_embed.daemon_embed_manager.platform.system", lambda: "Linux") + monkeypatch.setattr("hindsight_embed.daemon_embed_manager.subprocess.run", fake_run) + + assert DaemonEmbedManager._find_pid_on_port(9177) == 15774 + assert calls == [ + ["lsof", "-ti", ":9177", "-sTCP:LISTEN"], + ["ss", "-H", "-ltnp", "sport = :9177"], + ] + + def test_stop_ui_kills_recorded_and_configured_ports(tmp_path, monkeypatch): """After a UI-port change, stop_ui must kill BOTH the recorded (old, actually running) port and the configured (new) port — otherwise the old UI orphans.""" From 80d75f5e521ff176f22c2b2c01735ab86053e322 Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 17 Aug 2026 16:04:30 +0800 Subject: [PATCH 2/2] fix Linux PID lookup without socket tools --- .../hindsight_embed/daemon_embed_manager.py | 38 +++++++++++++++++++ hindsight-embed/tests/test_embed_manager.py | 32 +++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/hindsight-embed/hindsight_embed/daemon_embed_manager.py b/hindsight-embed/hindsight_embed/daemon_embed_manager.py index d008e25e8e..8020709f4c 100644 --- a/hindsight-embed/hindsight_embed/daemon_embed_manager.py +++ b/hindsight-embed/hindsight_embed/daemon_embed_manager.py @@ -43,6 +43,43 @@ def _parse_float_env(name: str, default: float) -> float: return default +def _find_linux_pid_via_proc(port: int, proc_root: Path = Path("/proc")) -> int | None: + """Resolve a listening TCP socket to its owner without external tools.""" + socket_inodes: set[str] = set() + for table in ("tcp", "tcp6"): + try: + lines = (proc_root / "net" / table).read_text().splitlines()[1:] + except OSError: + continue + + for line in lines: + fields = line.split() + if len(fields) > 9 and fields[3] == "0A": + try: + local_port = int(fields[1].rsplit(":", 1)[1], 16) + except (IndexError, ValueError): + continue + if local_port == port: + socket_inodes.add(fields[9]) + + if not socket_inodes: + return None + + for process_dir in proc_root.iterdir(): + if not process_dir.name.isdigit(): + continue + try: + descriptors = (process_dir / "fd").iterdir() + for descriptor in descriptors: + target = descriptor.readlink() + match = re.fullmatch(r"socket:\[(\d+)]", str(target)) + if match and match.group(1) in socket_inodes: + return int(process_dir.name) + except OSError: + continue + return None + + def _safe_non_negative_float(value: float, fallback: float) -> float: """Return a finite non-negative float, or fallback for invalid values.""" return value if math.isfinite(value) and value >= 0 else fallback @@ -373,6 +410,7 @@ def _find_pid_on_port(port: int) -> int | None: return int(match.group(1)) except (subprocess.TimeoutExpired, ValueError, OSError): pass + return _find_linux_pid_via_proc(port) return None @staticmethod diff --git a/hindsight-embed/tests/test_embed_manager.py b/hindsight-embed/tests/test_embed_manager.py index 0267b461d9..76e5131e0b 100644 --- a/hindsight-embed/tests/test_embed_manager.py +++ b/hindsight-embed/tests/test_embed_manager.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch from hindsight_embed import get_embed_manager -from hindsight_embed.daemon_embed_manager import DaemonEmbedManager +from hindsight_embed.daemon_embed_manager import DaemonEmbedManager, _find_linux_pid_via_proc def _mock_sentence_transformers_present(monkeypatch): @@ -407,6 +407,36 @@ def fake_run(command, **kwargs): ] +def test_find_linux_pid_via_proc_matches_listening_socket_inode(tmp_path): + proc_root = tmp_path / "proc" + (proc_root / "net").mkdir(parents=True) + (proc_root / "net" / "tcp").write_text( + "header\n" + " 0: 0100007F:23D9 00000000:0000 0A 00000000:00000000 00:00000000 " + "00000000 1000 0 424242 1 0000000000000000 100 0 0 10 0\n" + ) + (proc_root / "net" / "tcp6").write_text("header\n") + fd_dir = proc_root / "15774" / "fd" + fd_dir.mkdir(parents=True) + (fd_dir / "19").symlink_to("socket:[424242]") + + assert _find_linux_pid_via_proc(9177, proc_root) == 15774 + assert _find_linux_pid_via_proc(9178, proc_root) is None + + +def test_find_pid_on_port_linux_falls_back_to_proc(monkeypatch): + monkeypatch.setattr("hindsight_embed.daemon_embed_manager.platform.system", lambda: "Linux") + monkeypatch.setattr( + "hindsight_embed.daemon_embed_manager.subprocess.run", + MagicMock(side_effect=FileNotFoundError), + ) + proc_lookup = MagicMock(return_value=15774) + monkeypatch.setattr("hindsight_embed.daemon_embed_manager._find_linux_pid_via_proc", proc_lookup) + + assert DaemonEmbedManager._find_pid_on_port(9177) == 15774 + proc_lookup.assert_called_once_with(9177) + + def test_stop_ui_kills_recorded_and_configured_ports(tmp_path, monkeypatch): """After a UI-port change, stop_ui must kill BOTH the recorded (old, actually running) port and the configured (new) port — otherwise the old UI orphans."""