diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index f2356fa..abcaf6a 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -208,9 +208,21 @@ except Exception: # Headless bootstrap avoids herdr's "nested herdr is disabled" guard that # blocks a normal interactive `herdr --session ` launch from inside # an existing herdr pane (which is how MAM's own agents usually run). - nohup "$REAL_HERDR" --session "$_MAM_SESSION" server >/dev/null 2>&1 & - _mam_server_pid=$! - disown 2>/dev/null || true + # ISSUE-3: plain `nohup ... & disown` only ignores SIGHUP and drops bash's + # own job-table tracking - it does NOT create a new process group/session, + # so the daemon stays in the caller's PGID and dies when a process-group + # signal (SIGTERM/SIGINT from a test runner, timeout wrapper, or parent + # supervisor teardown) is broadcast to that group. setsid(1) is not + # installed by default on macOS, so spawn via Python's + # start_new_session=True (os.setsid in the child) instead - portable + # across macOS and Linux, and lib_py already depends on Python. + _mam_server_pid=$(python3 -c ' +import subprocess, sys +p = subprocess.Popen(sys.argv[1:], start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL) +print(p.pid) +' "$REAL_HERDR" --session "$_MAM_SESSION" server) for _mam_wait_i in $(seq 1 40); do [ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break kill -0 "$_mam_server_pid" 2>/dev/null || break diff --git a/tests/test_herdr_shim_contract.py b/tests/test_herdr_shim_contract.py index c34023f..9e8f81d 100644 --- a/tests/test_herdr_shim_contract.py +++ b/tests/test_herdr_shim_contract.py @@ -550,3 +550,49 @@ def test_h25_new_session_idle_pane_preserves_env_and_ws_id(mam_sandbox, mock_her split_calls = [c for c in new_calls if len(c) >= 2 and c[0] == "pane" and c[1] == "split"] assert not split_calls, f"Expected no pane split call when an idle pane was available: {split_calls}" + +def test_h26_daemon_spawn_uses_process_group_detachment(mam_sandbox, mock_herdr, mock_agents): + """H-26 (Issue #3): the headless herdr daemon bootstrap must fully detach into + its own process group/session rather than relying on nohup+disown, which only + masks SIGHUP and drops bash job-table tracking - it does NOT change PGID/SID, + so the daemon previously died on any process-group signal broadcast.""" + shim_path = mam_sandbox / ".mam" / "shim" / "herdr" + res = _run_lib(mam_sandbox, "true") + assert res.returncode == 0, res.stderr + shim = shim_path.read_text() + + assert 'nohup "$REAL_HERDR" --session "$_MAM_SESSION" server' not in shim + assert "start_new_session=True" in shim + + # Extract the actual shipped spawner statement (not a re-implementation) and + # run it standalone against a fake REAL_HERDR that just sleeps, to prove the + # spawned process ends up in a DIFFERENT process group than the caller shell. + start_marker = "_mam_server_pid=$(python3 -c '" + end_marker = "' \"$REAL_HERDR\" --session \"$_MAM_SESSION\" server)" + start = shim.index(start_marker) + end = shim.index(end_marker, start) + len(end_marker) + spawner_stmt = shim[start:end] + assert "start_new_session=True" in spawner_stmt + + fake_herdr = mam_sandbox / "fake_herdr.sh" + fake_herdr.write_text("#!/usr/bin/env bash\nexec sleep 30\n") + fake_herdr.chmod(0o755) + + script = f""" +set -euo pipefail +REAL_HERDR="{fake_herdr}" +_MAM_SESSION="testsess" +{spawner_stmt} +echo "PID=$_mam_server_pid" +echo "SHELL_PGID=$(ps -o pgid= -p $$ | tr -d ' ')" +sleep 0.3 +echo "CHILD_PGID=$(ps -o pgid= -p $_mam_server_pid | tr -d ' ')" +kill "$_mam_server_pid" 2>/dev/null || true +""" + res2 = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + assert res2.returncode == 0, f"stderr: {res2.stderr}\nstdout: {res2.stdout}" + fields = dict(line.split("=", 1) for line in res2.stdout.strip().splitlines()) + assert fields["CHILD_PGID"] != fields["SHELL_PGID"], ( + f"daemon child stayed in the caller's process group (not detached): {fields}" + ) +