From 9d021159f0fcd647bb9ed671ebac0ae5288d5077 Mon Sep 17 00:00:00 2001 From: J_Sinclair <56301946+E-R-Butch@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:02:57 +0800 Subject: [PATCH 1/2] fix(embed): pin stdin to DEVNULL when spawning the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POSIX branch of `_detach_popen_kwargs` left `stdin` unset, so the daemon child inherited the caller's fd 0. A caller can hold an fd 0 that is a socket opened with FD_CLOEXEC (e.g. a TUI/gateway that wires its IPC channel onto fds 0-2); the kernel closes an inherited fd 0 at exec, so the child starts with `sys.stdin = None` and dies in `_redirect_stdio_to_log()` with `AttributeError: 'NoneType' object has no attribute 'fileno'`. The Windows branch already pinned stdin, and so does the sibling helper in hindsight-api-slim/hindsight_api/daemon.py — the two copies had drifted. --- .../hindsight_embed/daemon_embed_manager.py | 5 ++++- hindsight-embed/tests/test_embed_manager.py | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/hindsight-embed/hindsight_embed/daemon_embed_manager.py b/hindsight-embed/hindsight_embed/daemon_embed_manager.py index b4887512f0..33f16a19c0 100644 --- a/hindsight-embed/hindsight_embed/daemon_embed_manager.py +++ b/hindsight-embed/hindsight_embed/daemon_embed_manager.py @@ -147,7 +147,9 @@ def _detach_popen_kwargs(log_handle: IO[bytes]) -> dict: """Cross-platform kwargs to spawn a subprocess detached from the caller. On POSIX, `start_new_session=True` calls setsid(2) so the child - survives the parent's terminal. On Windows there is no setsid: we use + survives the parent's terminal; `stdin` is pinned to /dev/null so the + child never inherits a caller fd 0 that may be CLOEXEC (closed at exec, + leaving ``sys.stdin = None``). On Windows there is no setsid: we use `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`, which also means the child has no console, so stdin/stdout/stderr MUST be redirected or any write from the child crashes with "handle is invalid". @@ -171,6 +173,7 @@ def _detach_popen_kwargs(log_handle: IO[bytes]) -> dict: } return { "start_new_session": True, + "stdin": subprocess.DEVNULL, "stdout": log_handle, "stderr": log_handle, } diff --git a/hindsight-embed/tests/test_embed_manager.py b/hindsight-embed/tests/test_embed_manager.py index 7af480b5dd..b02dd81de9 100644 --- a/hindsight-embed/tests/test_embed_manager.py +++ b/hindsight-embed/tests/test_embed_manager.py @@ -1,10 +1,12 @@ """Tests for EmbedManager interface.""" +import io +import subprocess from unittest.mock import MagicMock, patch from hindsight_embed import get_embed_manager from hindsight_embed._http_probe import ProbeResponse -from hindsight_embed.daemon_embed_manager import DaemonEmbedManager +from hindsight_embed.daemon_embed_manager import DaemonEmbedManager, _detach_popen_kwargs def _mock_sentence_transformers_present(monkeypatch): @@ -684,3 +686,15 @@ def test_run_probe_still_reports_a_failed_command_as_none(): import sys as _sys assert DaemonEmbedManager._run_probe([_sys.executable, "-c", "raise SystemExit(3)"]) is None + + +def test_detach_popen_kwargs_pins_stdin(): + """The daemon child must never inherit the caller's fd 0. + + A caller can hold an fd 0 that is a socket opened with FD_CLOEXEC (e.g. a + TUI/gateway parent that wires its IPC channel onto fds 0-2). An inherited + fd 0 is closed by the kernel at exec, so the child would start with + ``sys.stdin = None`` and crash in ``_redirect_stdio_to_log()``. + """ + kwargs = _detach_popen_kwargs(io.BytesIO()) + assert kwargs.get("stdin") == subprocess.DEVNULL From 13e165d6bd15c595fee729767010699537dec2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Fri, 25 Sep 2026 14:12:54 +0200 Subject: [PATCH 2/2] test(embed): index the stdin kwarg directly in the regression test Review fix: assert kwargs["stdin"] instead of kwargs.get("stdin"), so a missing key fails loudly as a KeyError. --- hindsight-embed/tests/test_embed_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hindsight-embed/tests/test_embed_manager.py b/hindsight-embed/tests/test_embed_manager.py index b02dd81de9..d0efaf96fa 100644 --- a/hindsight-embed/tests/test_embed_manager.py +++ b/hindsight-embed/tests/test_embed_manager.py @@ -697,4 +697,4 @@ def test_detach_popen_kwargs_pins_stdin(): ``sys.stdin = None`` and crash in ``_redirect_stdio_to_log()``. """ kwargs = _detach_popen_kwargs(io.BytesIO()) - assert kwargs.get("stdin") == subprocess.DEVNULL + assert kwargs["stdin"] == subprocess.DEVNULL