/ tests / tools / test_local_background_child_hang.py
test_local_background_child_hang.py
  1  """Regression tests for issue #8340.
  2  
  3  When a user command backgrounds a child process (``cmd &``, ``setsid cmd &
  4  disown``, etc.), the backgrounded grandchild inherits the write-end of our
  5  stdout pipe via fork().  Before the fix, the drain thread's blocking
  6  ``for line in proc.stdout`` would never see EOF until that grandchild
  7  closed the pipe — causing the terminal tool to hang for the full lifetime
  8  of the backgrounded service (indefinitely for a uvicorn server).
  9  
 10  The fix switches ``_drain()`` to select()-based non-blocking reads and
 11  stops draining shortly after bash exits even if the pipe hasn't EOF'd.
 12  """
 13  import json
 14  import subprocess
 15  import time
 16  
 17  import pytest
 18  
 19  from tools.environments.local import LocalEnvironment
 20  
 21  
 22  def _pkill(pattern: str) -> None:
 23      subprocess.run(f"pkill -9 -f {pattern!r} 2>/dev/null", shell=True)
 24  
 25  
 26  @pytest.fixture
 27  def local_env():
 28      env = LocalEnvironment(cwd="/tmp")
 29      try:
 30          yield env
 31      finally:
 32          env.cleanup()
 33  
 34  
 35  class TestBackgroundChildDoesNotHang:
 36      """Regression guard for issue #8340."""
 37  
 38      def test_plain_background_returns_promptly(self, local_env):
 39          """``cmd &`` with no output redirection must not hang on pipe inherit."""
 40          marker = "hermes_8340_plain_bg"
 41          cmd = f'python3 -c "import time; time.sleep(60)" & echo {marker}'
 42          try:
 43              t0 = time.monotonic()
 44              result = local_env.execute(cmd, timeout=15)
 45              elapsed = time.monotonic() - t0
 46  
 47              assert elapsed < 4.0, (
 48                  f"terminal_tool hung for {elapsed:.1f}s — drain thread "
 49                  f"is still blocking on backgrounded child's inherited pipe fd"
 50              )
 51              assert result["returncode"] == 0
 52              assert marker in result["output"]
 53          finally:
 54              _pkill("time.sleep(60)")
 55  
 56      def test_setsid_disown_pattern_returns_promptly(self, local_env):
 57          """The exact pattern from the issue: setsid ... & disown."""
 58          cmd = (
 59              'setsid python3 -c "import time; time.sleep(60)" '
 60              '> /dev/null 2>&1 < /dev/null & disown; echo started'
 61          )
 62          try:
 63              t0 = time.monotonic()
 64              result = local_env.execute(cmd, timeout=15)
 65              elapsed = time.monotonic() - t0
 66  
 67              assert elapsed < 4.0, f"setsid+disown path hung for {elapsed:.1f}s"
 68              assert result["returncode"] == 0
 69              assert "started" in result["output"]
 70          finally:
 71              _pkill("time.sleep(60)")
 72  
 73      def test_foreground_streaming_output_still_captured(self, local_env):
 74          """Sanity: incremental output over time must still be captured in full."""
 75          cmd = 'for i in 1 2 3; do echo "tick $i"; sleep 0.2; done; echo done'
 76          t0 = time.monotonic()
 77          result = local_env.execute(cmd, timeout=10)
 78          elapsed = time.monotonic() - t0
 79  
 80          # Loop body sleeps ~0.6s total — elapsed should be close to that.
 81          assert 0.5 < elapsed < 3.0
 82          assert result["returncode"] == 0
 83          for expected in ("tick 1", "tick 2", "tick 3", "done"):
 84              assert expected in result["output"], f"missing {expected!r}"
 85  
 86      def test_high_volume_output_complete(self, local_env):
 87          """Sanity: select-based drain must not drop lines under load."""
 88          result = local_env.execute("seq 1 3000", timeout=10)
 89          lines = result["output"].strip().split("\n")
 90          assert result["returncode"] == 0
 91          assert len(lines) == 3000
 92          assert lines[0] == "1"
 93          assert lines[-1] == "3000"
 94  
 95      def test_timeout_path_still_works(self, local_env):
 96          """Foreground command exceeding timeout must still be killed."""
 97          t0 = time.monotonic()
 98          result = local_env.execute("sleep 30", timeout=2)
 99          elapsed = time.monotonic() - t0
100  
101          assert elapsed < 4.0
102          assert result["returncode"] == 124
103          assert "timed out" in result["output"].lower()
104  
105      def test_utf8_output_decoded_correctly(self, local_env):
106          """Multibyte UTF-8 chunks must decode cleanly under select-based reads."""
107          result = local_env.execute("echo 日本語 café résumé", timeout=5)
108          assert result["returncode"] == 0
109          assert "日本語" in result["output"]
110          assert "café" in result["output"]
111          assert "résumé" in result["output"]
112  
113      def test_utf8_multibyte_across_read_boundary(self, local_env):
114          """Multibyte UTF-8 characters straddling a 4096-byte ``os.read()`` boundary
115          must be decoded correctly via the incremental decoder — not lost to a
116          ``UnicodeDecodeError`` fallback.  Regression for a bug in the first draft
117          of the fix where a strict ``bytes.decode('utf-8')`` on each raw chunk
118          wiped the entire buffer as soon as any chunk split a multi-byte char.
119          """
120          # 10000 "日" chars = 30000 bytes — guaranteed to cross multiple 4096
121          # read boundaries, and most boundaries will land in the middle of the
122          # 3-byte UTF-8 encoding of U+65E5.
123          cmd = (
124              'python3 -c \'import sys; '
125              'sys.stdout.buffer.write(chr(0x65e5).encode("utf-8") * 10000); '
126              'sys.stdout.buffer.write(b"\\n")\''
127          )
128          result = local_env.execute(cmd, timeout=10)
129          assert result["returncode"] == 0
130          # All 10000 characters must survive the round-trip
131          assert result["output"].count("\u65e5") == 10000, (
132              f"lost multibyte chars across read boundaries: got "
133              f"{result['output'].count(chr(0x65e5))} / 10000"
134          )
135          # And the "[binary output detected ...]" fallback must NOT fire
136          assert "binary output detected" not in result["output"]
137  
138      def test_invalid_utf8_uses_replacement_not_fallback(self, local_env):
139          """Truly invalid byte sequences must be substituted with U+FFFD (matching
140          the pre-fix ``errors='replace'`` behaviour of the old ``TextIOWrapper``
141          drain), not clobber the entire buffer with a fallback placeholder.
142          """
143          # Write a deliberate invalid UTF-8 lead byte sandwiched between valid ASCII
144          cmd = (
145              'python3 -c \'import sys; '
146              'sys.stdout.buffer.write(b"before "); '
147              'sys.stdout.buffer.write(b"\\xff\\xfe"); '
148              'sys.stdout.buffer.write(b" after\\n")\''
149          )
150          result = local_env.execute(cmd, timeout=5)
151          assert result["returncode"] == 0
152          assert "before" in result["output"]
153          assert "after" in result["output"]
154          assert "binary output detected" not in result["output"]