fix(herdr): detach daemon bootstrap into own process group via python spawner
- Replace 'nohup ... server & disown' with portable Python 'Popen(..., start_new_session=True)' in lib.sh shim generator - Isolate headless herdr server in its own SID/PGID to protect against parent process-group signal broadcast (SIGTERM/SIGINT) - Add regression test test_h26_daemon_spawn_uses_process_group_detachment to verify child PGID detachment (Closes #3 Item 1)
This commit is contained in:
+15
-3
@@ -208,9 +208,21 @@ except Exception:
|
|||||||
# Headless bootstrap avoids herdr's "nested herdr is disabled" guard that
|
# Headless bootstrap avoids herdr's "nested herdr is disabled" guard that
|
||||||
# blocks a normal interactive `herdr --session <name>` launch from inside
|
# blocks a normal interactive `herdr --session <name>` launch from inside
|
||||||
# an existing herdr pane (which is how MAM's own agents usually run).
|
# an existing herdr pane (which is how MAM's own agents usually run).
|
||||||
nohup "$REAL_HERDR" --session "$_MAM_SESSION" server >/dev/null 2>&1 &
|
# ISSUE-3: plain `nohup ... & disown` only ignores SIGHUP and drops bash's
|
||||||
_mam_server_pid=$!
|
# own job-table tracking - it does NOT create a new process group/session,
|
||||||
disown 2>/dev/null || true
|
# 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
|
for _mam_wait_i in $(seq 1 40); do
|
||||||
[ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break
|
[ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break
|
||||||
kill -0 "$_mam_server_pid" 2>/dev/null || break
|
kill -0 "$_mam_server_pid" 2>/dev/null || break
|
||||||
|
|||||||
@@ -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"]
|
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}"
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user