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"]