fix(issue-19): arm the subscriber's stop handlers before it connects; read $! with nounset off

Review round 3 (reviewer-grok-01, NOT PASS N1; non-blocking B1):

- reconcile.sh (N1): the python subscriber installs its SIGTERM/SIGINT
  handlers before connect/loop_start. A signal during the broker handshake,
  the CONNACK wait, or right after the paho thread logs "subscribed to" now
  takes the clean path (the signal line, loop_stop/disconnect, rc 0) instead
  of Python's default action; the CONNACK wait ends on it, and a signalled
  subscriber never falls back to polling (rc 3).
- reconcile.sh (B1): `_sub_stop` reads `$!` with nounset off. On bash 3.2
  `${!:-}` and `${!+x}` are both unbound before the first `&`, so the trap
  died with rc 1 instead of 143.
- tests: SIGTERM during a CONNACK wait against a silent local listener (no
  broker needed), and the verbatim `_sub_stop` run without and with a
  child; both fail on 8f9745b.
- docs: monitor SKILL.md and VERSIONS.md state the handler order; test
  count 16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-13 17:22:39 +09:00
1 parent 8f9745b128
commit 134fe9704c
4 files changed
+97 -27

No files matched your search

@@ -358,6 +358,58 @@ def test_poll_fallback_sigterm_exits_promptly(mam_sandbox, mock_herdr, monkeypat
proc.kill()
def test_sigterm_during_broker_handshake_takes_the_clean_path(mam_sandbox, mock_herdr, monkeypatch):
"""Review N1: the python handlers are installed before connect/loop_start,
so a SIGTERM while the subscriber still waits for CONNACK (a local listener
that never answers) exits through the same clean path as a later one - the
"signal 15 received" line, rc 143 from the wrapper - and never drops into
the polling fallback."""
import socket
monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False)
silent = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
silent.bind(("127.0.0.1", 0))
silent.listen(1) # the kernel completes the TCP handshake; no CONNACK ever comes
log = mam_sandbox / "sub.log"
proc = _start_subscriber(mam_sandbox, log, {
"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": str(silent.getsockname()[1]), "MQTT_TLS": "0",
})
try:
assert _wait_for(log, r"MQTT Monitor: connecting to 127\.0\.0\.1", 30), log.read_text()
time.sleep(1.0) # well inside the 5 s CONNACK wait
t0 = time.time()
proc.send_signal(signal.SIGTERM)
rc = _wait_exit(proc, 10)
assert rc is not None, "SIGTERM was deferred: daemon still running after 10s"
assert time.time() - t0 < 5
assert rc == 143, (rc, log.read_text())
text = log.read_text()
assert "signal 15 received, exiting" in text, text
assert "falling back to polling" not in text, text
finally:
if proc.poll() is None:
proc.kill()
silent.close()
def test_sub_stop_trap_exits_143_before_the_first_background_child():
"""Review B1: the wrapper's `_sub_stop` trap must end with rc 143 even when
the signal lands before the first `&` - bash 3.2 treats `$!` as unbound
under `set -u` there, and the trap died with rc 1. The function is taken
verbatim from reconcile.sh and run without and with a child to stop."""
src = (Path(__file__).resolve().parent.parent / ".agents" / "skills" / "multi-agent-mux-monitor"
/ "scripts" / "reconcile.sh").read_text(encoding="utf-8")
m = re.search(r"^ _sub_stop\(\) \{\n.*?^ \}\n", src, re.S | re.M)
assert m, "_sub_stop not found in reconcile.sh"
# With a child, let it exec first: a TERM forwarded in the instant between
# its fork and its exec is swallowed by the child's copy of the trap - a
# separate window from B1, which is about reading `$!` at all.
for child in ("", "sleep 30 &\nsleep 0.5\n"):
script = "set -euo pipefail\n_sub_pid=''\n{}trap _sub_stop TERM INT\n{}kill -TERM $$\nsleep 5\n".format(m.group(0), child)
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=30)
assert res.returncode == 143, (child, res.returncode, res.stderr)
# ---------------------------------------------------------------------------
# 4. self-termination and signals - MQTT loop (needs a reachable broker)
# ---------------------------------------------------------------------------