From 134fe9704c63bd9b1f46b64b01868f904f5a33cc Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 17:22:39 +0900 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe --- .../skills/multi-agent-mux-monitor/SKILL.md | 4 +- .../scripts/reconcile.sh | 64 ++++++++++++------- VERSIONS.md | 4 +- ...est_issue19_subscriber_daemon_lifecycle.py | 52 +++++++++++++++ 4 files changed, 97 insertions(+), 27 deletions(-) diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index 45159e3..6a5e70a 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -84,7 +84,9 @@ per workspace through `lib.sh::start_watchdog` (`lib_py.delegation.daemon`). Tha the lock (the python subscriber released it on exit), so while polling it does not exclude a second daemon, as before; - **stops promptly on `SIGTERM`/`SIGINT`** β€” the bash wrapper forwards the signal to the - python subscriber and exits 143. In the broker-down polling fallback the signal cuts the + python subscriber and exits 143; the subscriber's handlers are in place before it + connects, so a signal during the broker handshake or right after it subscribes takes + the same clean path. In the broker-down polling fallback the signal cuts the interval wait short, but a reconcile pass (`--once`) already running finishes first, and the interval's `sleep` child is left to end on its own (≀ `RECONCILE_POLL_INTERVAL`). `SIGKILL` is never required; diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 1d0dffa..fbf25fc 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -80,8 +80,15 @@ if [ "$SUBSCRIBE" = "1" ]; then _sub_stop() { trap - TERM INT # `$!` covers the window between `&` and `_sub_pid=$!` (review R-1); a - # child that already exited makes kill/wait harmless no-ops. - local child="${_sub_pid:-${!:-}}" + # child that already exited makes kill/wait harmless no-ops. Before the + # first `&` bash 3.2 treats `$!` as unbound under `set -u` - `${!:-}` and + # `${!+x}` included - so it is read with nounset off (review B1). + local child="${_sub_pid:-}" + if [ -z "$child" ]; then + set +u + child="$!" + set -u + fi if [ -n "$child" ]; then kill -TERM "$child" 2>/dev/null || true wait "$child" 2>/dev/null || true @@ -290,28 +297,6 @@ def on_connect(_c, _u, _flags, reason_code, _props): print(f"MQTT Monitor connection failed: rc={rc}", flush=True) -cfg = mqtt_common.broker_config_from_env() -client = mqtt_common.make_client("monitor_sub", cfg) -client.on_message = on_message -client.on_connect = on_connect -print(f"MQTT Monitor: connecting to {cfg.host}:{cfg.port} (TLS={cfg.tls})...", flush=True) - -# Connection failure β†’ fall back to polling (review item 4). -try: - client.connect(cfg.host, cfg.port, cfg.keepalive) -except Exception as e: - print(f"MQTT Monitor: connect failed ({e}); falling back to polling", flush=True) - sys.exit(3) - -client.loop_start() -_wait = time.time() -while time.time() - _wait < 5 and not state['connected'] and not state['failed']: - time.sleep(0.1) -if not state['connected']: - print("MQTT Monitor: broker did not accept connection; falling back to polling", flush=True) - client.loop_stop() - sys.exit(3) - import threading, signal stop_event = threading.Event() state['stop_signal'] = None @@ -322,8 +307,39 @@ def _on_stop_signal(signum, _frame): stop_event.set() +# Issue #19 (review N1): the handlers go in before connect/loop_start. The paho +# thread logs "subscribed to" from on_connect, and a SIGTERM during the broker +# handshake or right after that line must take the same clean path as a later +# one - the "signal N received" line and loop_stop/disconnect - not Python's +# default action. A signalled subscriber exits 0, never 3: rc 3 would send the +# bash wrapper into the polling fallback. signal.signal(signal.SIGTERM, _on_stop_signal) signal.signal(signal.SIGINT, _on_stop_signal) + +cfg = mqtt_common.broker_config_from_env() +client = mqtt_common.make_client("monitor_sub", cfg) +client.on_message = on_message +client.on_connect = on_connect +print(f"MQTT Monitor: connecting to {cfg.host}:{cfg.port} (TLS={cfg.tls})...", flush=True) + +# Connection failure β†’ fall back to polling (review item 4). +try: + client.connect(cfg.host, cfg.port, cfg.keepalive) +except Exception as e: + if state['stop_signal'] is None: + print(f"MQTT Monitor: connect failed ({e}); falling back to polling", flush=True) + sys.exit(3) +else: + client.loop_start() + _wait = time.time() + while (time.time() - _wait < 5 and not state['connected'] and not state['failed'] + and not stop_event.is_set()): + stop_event.wait(0.1) + if not state['connected'] and state['stop_signal'] is None: + print("MQTT Monitor: broker did not accept connection; falling back to polling", flush=True) + client.loop_stop() + sys.exit(3) + start = time.time() try: while True: diff --git a/VERSIONS.md b/VERSIONS.md index 214d17c..403ce79 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -44,11 +44,11 @@ #### 🌟 μ£Όμš” κ°œμ„  사항 1. **`MAM_NO_SUBSCRIBER_DAEMON=1` μ˜΅νŠΈμ•„μ›ƒ (`lib_py/delegation/daemon.py`, `lib.sh: _start_watchdog_legacy`)**: λ¦¬ν„°λŸ΄ `1` 일 λ•Œλ§Œ 데λͺ¬μ„ μƒˆλ‘œ λ„μš°μ§€ μ•ŠλŠ”λ‹€. 이미 λ„λŠ” 데λͺ¬μ˜ PID λŠ” κ·ΈλŒ€λ‘œ λ³΄κ³ ν•˜λ―€λ‘œ `start_watchdog`/`ensure_subscriber_daemon` 의 dedup 계약은 μœ μ§€λ˜κ³ , 미기동 μ‹œ stderr 에 ν•œ 쀄 남긴닀(stdout 은 비움). ν…ŒμŠ€νŠΈ μƒŒλ“œλ°•μŠ€(`mam_sandbox`)κ°€ 기본으둜 켜고, 데λͺ¬ 자체λ₯Ό κ²€μ¦ν•˜λŠ” ν…ŒμŠ€νŠΈλ§Œ `0`/μ‚­μ œλ‘œ λ˜λŒλ¦°λ‹€. 2. **데λͺ¬ 자기 μ’…λ£Œ (`reconcile.sh --subscribe`)**: MQTT λ£¨ν”„λŠ” 5초 μ£ΌκΈ° wake λ§ˆλ‹€ μ›Œν¬μŠ€νŽ˜μ΄μŠ€ 디렉터리 μ‘΄μž¬μ™€ `.mam/monitor.lock` 의 `(st_dev, st_ino)` κ°€ μžμ‹ μ΄ μž‘μ€ 것과 같은지 ν™•μΈν•˜κ³ , μ‚¬λΌμ‘Œκ±°λ‚˜ ꡐ체됐으면(`.mam/` μž¬μƒμ„± β†’ μƒˆ 데λͺ¬μ΄ μƒˆ lock 을 μž‘μ„ 수 μžˆλŠ” μƒνƒœ) 정리 ν›„ rc 0 으둜 λλ‚œλ‹€. 브둜컀 λΆˆκ°€ 폴링 폴백은 λ§€ 회 디렉터리와 lock 파일의 **쑴재만** ν™•μΈν•œλ‹€(python μžμ‹μ΄ λλ‚˜λ©° lock 을 λ†“μ•˜μœΌλ―€λ‘œ 폴백 쀑 두 번째 데λͺ¬μ„ λ°°μ œν•˜μ§€λŠ” λͺ»ν•œλ‹€ β€” μ’…μ „ λ™μž‘). μ‚­μ œλœ pytest μž„μ‹œ λ””λ ‰ν„°λ¦¬λ‚˜ `rm -rf` 된 ν”„λ‘œμ νŠΈλ₯Ό 바라보며 곡개 브둜컀 ꡬ독을 μ˜μ›νžˆ μœ μ§€ν•˜λ˜ 데λͺ¬μ΄ 사라진닀. -3. **`SIGTERM`/`SIGINT` μ¦‰μ‹œ 전달 (`reconcile.sh`)**: python κ΅¬λ…μžλ₯Ό λ°±κ·ΈλΌμš΄λ“œ μžμ‹μœΌλ‘œ 두고 bash κ°€ `wait` ν•˜λ„λ‘ λ°”κΏ” μ‹œκ·Έλ„μ΄ 미뀄지지 μ•ŠλŠ”λ‹€(μ’…μ „μ—” ν¬κ·ΈλΌμš΄λ“œ μžμ‹μ΄ 끝날 λ•ŒκΉŒμ§€ bash κ°€ μ‹œκ·Έλ„μ„ 보λ₯˜ν•΄ `SIGKILL` 만 ν†΅ν–ˆλ‹€). 트랩이 μžμ‹μ— `SIGTERM` 을 μ „λ‹¬ν•˜κ³  rc 143 으둜 λλ‚˜λ©°, python μͺ½μ€ ν•Έλ“€λŸ¬λ‘œ 이벀트λ₯Ό κΉ¨μ›Œ `loop_stop`/`disconnect` λ₯Ό κ±°μΉœλ‹€. 폴백 λ£¨ν”„μ˜ λŒ€κΈ°λ„ `sleep … & wait` 둜 λ°”κΏ” μ‹œκ·Έλ„μ΄ κ³§λ°”λ‘œ μ²˜λ¦¬λœλ‹€(끊긴 λŒ€κΈ°μ˜ `sleep` μžμ‹μ€ 남은 간격 μ•ˆμ— 슀슀둜 λλ‚˜κ³ , 이미 돌고 μžˆλŠ” `--once` μ‘°μ • 1νšŒλŠ” λλ‚œ λ’€ μ²˜λ¦¬λœλ‹€). +3. **`SIGTERM`/`SIGINT` μ¦‰μ‹œ 전달 (`reconcile.sh`)**: python κ΅¬λ…μžλ₯Ό λ°±κ·ΈλΌμš΄λ“œ μžμ‹μœΌλ‘œ 두고 bash κ°€ `wait` ν•˜λ„λ‘ λ°”κΏ” μ‹œκ·Έλ„μ΄ 미뀄지지 μ•ŠλŠ”λ‹€(μ’…μ „μ—” ν¬κ·ΈλΌμš΄λ“œ μžμ‹μ΄ 끝날 λ•ŒκΉŒμ§€ bash κ°€ μ‹œκ·Έλ„μ„ 보λ₯˜ν•΄ `SIGKILL` 만 ν†΅ν–ˆλ‹€). 트랩이 μžμ‹μ— `SIGTERM` 을 μ „λ‹¬ν•˜κ³  rc 143 으둜 λλ‚˜λ©°(첫 λ°±κ·ΈλΌμš΄λ“œ μžμ‹ μ „μ˜ μ‹œκ·Έλ„ 포함), python μͺ½ ν•Έλ“€λŸ¬λŠ” 브둜컀 μ—°κ²°(`connect`/`loop_start`) 전에 μ„€μΉ˜λ˜μ–΄ ν•Έλ“œμ…°μ΄ν¬Β·CONNACK λŒ€κΈ° μ€‘μ΄λ‚˜ ꡬ독 μ§ν›„μ˜ μ‹œκ·Έλ„λ„ 이벀트λ₯Ό κΉ¨μ›Œ `loop_stop`/`disconnect` λ₯Ό 거친 λ’€ rc 0 으둜 λλ‚œλ‹€(μ‹œκ·Έλ„μ„ 받은 λ’€μ—λŠ” 폴링 폴백으둜 κ°€μ§€ μ•ŠλŠ”λ‹€). 폴백 λ£¨ν”„μ˜ λŒ€κΈ°λ„ `sleep … & wait` 둜 λ°”κΏ” μ‹œκ·Έλ„μ΄ κ³§λ°”λ‘œ μ²˜λ¦¬λœλ‹€(끊긴 λŒ€κΈ°μ˜ `sleep` μžμ‹μ€ 남은 간격 μ•ˆμ— 슀슀둜 λλ‚˜κ³ , 이미 돌고 μžˆλŠ” `--once` μ‘°μ • 1νšŒλŠ” λλ‚œ λ’€ μ²˜λ¦¬λœλ‹€). 4. **μƒŒλ“œλ°•μŠ€ νšŒμˆ˜Β·νšŒκ·€ κ°€λ“œ (`tests/conftest.py`)**: `mam_sandbox` finalizer κ°€ argv ν˜•νƒœ(`bash|sh <μƒŒλ“œλ°•μŠ€>/…/reconcile.sh --subscribe`)둜 μ„ μ •ν•œ 데λͺ¬κ³Ό κ·Έ μžμ‹μ„ `SIGTERM`β†’`SIGKILL` 둜 νšŒμˆ˜ν•œλ‹€(λΈŒλ¦¬ν”„λ₯Ό argv 에 싀은 λž˜νΌλ‚˜ 접두 좩돌 ν˜•μ œ λ£¨νŠΈλŠ” μ„ μ •λ˜μ§€ μ•ŠμŒ). `ps -o args=` λŠ” argv λ₯Ό λ”°μ˜΄ν‘œ 없이 곡백으둜 이어 λΆ™μ΄λ―€λ‘œ 슀크립트 κ²½λ‘œλŠ” 곡백 토큰이 μ•„λ‹ˆλΌ μ…Έ 단어뢀터 첫 `/reconcile.sh --subscribe` κΉŒμ§€ ν†΅μ§Έλ‘œ μž‘λŠ”λ‹€ β€” 곡백이 λ“  μƒŒλ“œλ°•μŠ€Β·`--basetemp` 경둜의 데λͺ¬λ„ μ„ μ •λ˜κ³ , 곡백이 λ“  ν›„λ³΄λŠ” μ‹€μ œ 파일일 λ•Œλ§Œ 인정해 λ’€μͺ½ μΈμžμ— 같은 낱말을 싀은 bash λž˜νΌλŠ” λ°°μ œν•œλ‹€. μ„Έμ…˜ μŠ€μ½”ν”„ `subscriber_daemon_leak_guard` λŠ” μŠ€μœ„νŠΈ μ’…λ£Œ μ‹œ basetemp μ•„λž˜ 데λͺ¬μ΄ 남아 있으면 νšŒμˆ˜ν•œ λ’€ μ‹€νŒ¨μ‹œν‚¨λ‹€(#15 λ ˆμ§€μŠ€νŠΈλ¦¬ κ°€λ“œμ™€ 같은 방식). #### βœ… ν’ˆμ§ˆ 검증 -- **μ‹ κ·œ ν…ŒμŠ€νŠΈ 14개** (`tests/test_issue19_subscriber_daemon_lifecycle.py`): μƒŒλ“œλ°•μŠ€ κΈ°λ³Έκ°’μ—μ„œ 두 helper 본체 λͺ¨λ‘ 데λͺ¬ 미기동(2), μ˜΅νŠΈμ•„μ›ƒ 쀑에도 κΈ°μ‘΄ 데λͺ¬ PID 보고, λ¦¬ν„°λŸ΄ `1` 만 인정, `create_session.sh` μƒŒλ“œλ°•μŠ€ μ‹€ν–‰ ν›„ 데λͺ¬ μ—†μŒ, μ˜΅νŠΈμ•„μ›ƒ ν•΄μ œ μ‹œ λˆ„μˆ˜ μž¬ν˜„ + 회수기 제거, 회수기 λ‹¨μœ„ 검증·argv μ„ μ • κΈ°μ€€ 검증, 곡백이 λ“  경둜의 argv μ„ μ •Β·μ‹€μ œ 데λͺ¬ 회수(2), 폴링 폴백의 lock μ†Œμ‹€ μ’…λ£ŒΒ·`SIGTERM` μ¦‰μ‹œ μ’…λ£Œ(2, 브둜컀 λΆˆν•„μš”), MQTT λ£¨ν”„μ˜ lock ꡐ체 μ’…λ£ŒΒ·`SIGTERM` 정상 μ’…λ£Œ(2, 브둜컀 미도달 μ‹œ skip). +- **μ‹ κ·œ ν…ŒμŠ€νŠΈ 16개** (`tests/test_issue19_subscriber_daemon_lifecycle.py`): μƒŒλ“œλ°•μŠ€ κΈ°λ³Έκ°’μ—μ„œ 두 helper 본체 λͺ¨λ‘ 데λͺ¬ 미기동(2), μ˜΅νŠΈμ•„μ›ƒ 쀑에도 κΈ°μ‘΄ 데λͺ¬ PID 보고, λ¦¬ν„°λŸ΄ `1` 만 인정, `create_session.sh` μƒŒλ“œλ°•μŠ€ μ‹€ν–‰ ν›„ 데λͺ¬ μ—†μŒ, μ˜΅νŠΈμ•„μ›ƒ ν•΄μ œ μ‹œ λˆ„μˆ˜ μž¬ν˜„ + 회수기 제거, 회수기 λ‹¨μœ„ 검증·argv μ„ μ • κΈ°μ€€ 검증, 곡백이 λ“  경둜의 argv μ„ μ •Β·μ‹€μ œ 데λͺ¬ 회수(2), 폴링 폴백의 lock μ†Œμ‹€ μ’…λ£ŒΒ·`SIGTERM` μ¦‰μ‹œ μ’…λ£Œ(2, 브둜컀 λΆˆν•„μš”), CONNACK λŒ€κΈ° 쀑 `SIGTERM` 정상 μ’…λ£Œ(응닡 μ—†λŠ” 둜컬 λ¦¬μŠ€λ„ˆ, 브둜컀 λΆˆν•„μš”), 첫 λ°±κ·ΈλΌμš΄λ“œ μžμ‹ μ „μ˜ μ‹œκ·Έλ„μ—λ„ `_sub_stop` 트랩 rc 143(bash 3.2 `set -u`), MQTT λ£¨ν”„μ˜ lock ꡐ체 μ’…λ£ŒΒ·`SIGTERM` 정상 μ’…λ£Œ(2, 브둜컀 미도달 μ‹œ skip). - **κΈ°μ‘΄ ν…ŒμŠ€νŠΈ μ‘°μ •**: `tests/test_issue5_phase1_delegation.py` 의 watchdog/daemon νŠΉμ„±ν™” ν…ŒμŠ€νŠΈκ°€ `MAM_NO_SUBSCRIBER_DAEMON=0`(λ˜λŠ” delenv)으둜 μ˜΅νŠΈμ•„μ›ƒμ„ ν•΄μ œ. - **μ‹€μΈ‘**: 전체 μŠ€μœ„νŠΈ(870 톡과) ν›„ pytest basetemp μ•„λž˜ `bash …/reconcile.sh --subscribe` 데λͺ¬ 0건(argv ν˜•νƒœ κΈ°μ€€; raw `pgrep -f` λŠ” λΈŒλ¦¬ν”„λ₯Ό 싀은 루프 λ“œλΌμ΄λ²„Β·λž˜νΌλ„ λ§€μΉ˜ν•˜λ―€λ‘œ νŒμ •μ— μ“°μ§€ μ•ŠμŒ). diff --git a/tests/test_issue19_subscriber_daemon_lifecycle.py b/tests/test_issue19_subscriber_daemon_lifecycle.py index 23e5d9f..7b37e0e 100644 --- a/tests/test_issue19_subscriber_daemon_lifecycle.py +++ b/tests/test_issue19_subscriber_daemon_lifecycle.py @@ -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) # ---------------------------------------------------------------------------