Round 3 at 134fe97: claude, agy, grok and codex all PASS.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
16 KiB
16 KiB
🔍 Review Report — Job ff5f9925 (branch fix/issue-19, reviewed HEAD 134fe97)
- Reviewer:
planner-reviewer-claude-01(role: planner,reviewer) · 2026-09-13 - 대상:
git log 8e7282c..HEAD6커밋,git diff 8e7282c...HEAD22파일 +791/−50.- 커밋:
ce315d8(fix #19),38d8ccb(release v4.4.5),4da4a50,c096cb4,8f9745b,134fe97. - 이번 라운드에 새로 들어온 것은
134fe97하나다. grok 의 N1·B1 을 반영했고, 4파일 +97/−27 이다. - 브리프에는 HEAD 가
38d8ccb로 적혀 있다. 실제로 검토한 HEAD 는134fe97이며,completed발행 명령에서 HEAD 를 다시 확인한다.
- 커밋:
- 방법:
- 브랜치 전체 diff 와
134fe97을 정독했다. - macOS
/bin/bash3.2.57(PATH 의 bash 와 같다)에서 B1 을 재현하고 수정을 확인했다. - paho 2.1.0 의 연결 중 시그널 지연을 데몬 없이 python 단독으로 실측했다.
- 선택 테스트 5파일을 실행하고, 신규 시그널 테스트를 3회 반복했다. 정적 검사도 했다.
- 리뷰 중 45초 간격으로
progress를 발행했다. 코드는 수정하지 않았다.
- 브랜치 전체 diff 와
- 안전 규칙 준수:
- 실제 monitor 데몬 35505(이 저장소)와 8152(다른 프로젝트), 그리고
.mam/monitor.lock은 읽기 전용ps로만 확인했다. scrub-secrets·purge 는 실행하지 않았다.- 데몬·트랩 실험은 pytest 샌드박스,
bash -c단독 스크립트, python 단독 프로세스에서만 했다.
- 실제 monitor 데몬 35505(이 저장소)와 8152(다른 프로젝트), 그리고
0. 이전 리뷰와 이번 커밋
| 리뷰 | 대상 HEAD | 판정 | 요지 |
|---|---|---|---|
planner-reviewer-claude-01(9114ac86) |
8f9745b |
PASS | B1 을 놓쳤다(§0-1) |
reviewer-agy-01(0f0327f6) |
8f9745b |
PASS | — |
reviewer-grok-01(0dc6400b) |
8f9745b |
NOT PASS | N1(차단): 구독 직후의 SIGTERM 이 정리 경로를 타지 않아 test_mqtt_loop_sigterm_exits_cleanly 가 실패. B1(비차단): 첫 & 전의 TERM 에서 트랩이 rc 1 로 끝남 |
planner-reviewer-codex-01(236cc993) |
8f9745b |
PASS | — |
creator(fdb85679)는 반론 없이 N1·B1 을 134fe97 에 반영했다. N1 은 grok 의 대안 1(핸들러를 연결 전에 설치), B1 은 set +u 로 읽는 방식이다.
0-1. 지난 리뷰의 누락 인정
- 나는
9114ac86§1-1 에서${_sub_pid:-${!:-}}를 승인했다. 근거는 "$!는 비어 있거나 구독자 자신" 이라는 추론뿐이었고, 대상 셸인 bash 3.2 에서 실제로 실행해 보지 않았다. - bash 3.2 는
set -u아래에서 첫&전에${!:-}를$!: unbound variable로 처리해 셸을 죽인다. 이것을 grok 이 찾았다(B1). - 이번에는 트랩과
set -u경로를/bin/bash3.2.57 에서 직접 실행해 확인했다(§1-1).
1. 134fe97 검증
1-1. B1: _sub_stop 의 $! 읽기 (reconcile.sh:80-98)
수정 내용(:86-91): 먼저 local child="${_sub_pid:-}" 로 읽고, 비어 있을 때만 set +u; child="$!"; set -u 로 $! 를 읽는다.
실측 (/bin/bash 3.2.57, set -euo pipefail) |
결과 |
|---|---|
set -u; x="${!:-}" (수정 전 방식), 첫 & 전 |
$!: unbound variable. B1 재현 |
수정 전 트랩 본문 + kill -TERM $$, 자식 없음 |
같은 오류로 rc 1 |
수정 후 _sub_stop 원문, 자식 없음 |
rc 143 |
수정 후 _sub_stop 원문, sleep 30 & 자식 있음 |
rc 143. sleep 도 종료됨 |
set +u가 풀려 있는 구간은 대입 한 줄뿐이다. 트랩은 곧바로exit 143으로 끝나므로 nounset 해제가 다른 코드로 새지 않는다.- 테스트
test_sub_stop_trap_exits_143_before_the_first_background_child(tests/test_issue19_subscriber_daemon_lifecycle.py:395-411):_sub_stop을 정규식으로 원문 그대로 추출한다. 정규식은 두 칸 들여쓴 첫}까지 잡는데, 함수 본문의fi는 네 칸 들여쓰기라 경계가 정확하다.set -euo pipefail인bash -c에서 자식 없이 한 번, 자식이 있는 상태로 한 번 실행한다.- 자식이 있는 경우는 0.5 s 기다린 뒤 TERM 을 보낸다. §1-3 의 fork-exec 창과 섞이지 않게 분리한 것이다.
- 이 테스트는 PATH 의
bash로 실행된다. 따라서 B1 회귀는 bash 3.2 환경(macOS)에서 잡힌다. 이 머신에는 bash 4 이상이 없어 다른 버전의 동작은 확인하지 못했다.
1-2. N1: python 핸들러를 연결 전에 설치 (reconcile.sh:300-377)
순서:
stop_event,_on_stop_signal,signal.signal×2 (:300-317)- 클라이언트 생성 (
:319-323) connect(:326-331)loop_start+ CONNACK 대기 (:332-341)- 메인 루프 (
:343-370) finally에서loop_stop/disconnect(:371-376)sys.exit(0)(:377)
| 시그널이 도착한 시점 | 동작 | 결과 |
|---|---|---|
| 인터프리터 기동·import 중 (핸들러 설치 전) | python 기본 동작으로 즉시 종료 | 연결·구독 전이라 정리할 것이 없다. lock 은 프로세스 종료로 풀리고 bash 는 143 |
connect 중 (TCP 연결 대기) |
핸들러는 플래그만 세우고, connect 는 PEP 475 재시도로 시간 초과까지 계속된다. 예외가 나면 stop_signal 이 있으므로 폴백하지 않는다(:329). 메인 루프 첫 검사(:346)에서 signal 15 received 를 찍고 finally 로 간다 |
rc 0, 폴백 없음. 최대 약 5 s 지연(§3-1) |
connect 성공 뒤 CONNACK 대기 중 |
stop_event.wait(0.1) 이 곧바로 깨어나 대기 루프를 빠져나온다(:335-337). stop_signal 이 있으므로 폴백하지 않는다(:338) |
즉시, rc 0 |
구독 직후 (subscribed to 로그 뒤) |
메인 루프의 stop_event.wait 이 깨어난다 |
즉시, rc 0. grok N1 의 사례 |
loop_start 전 (connect 예외 경로) |
finally 의 loop_stop() 은 스레드가 없어 MQTT_ERR_INVAL 을 반환할 뿐이다(paho 2.1.0). disconnect 는 try 안에 있다 |
예외 없음 |
- rc 계약:
- 시그널을 받은 구독자는 rc 3 을 내지 않는다(
:329,:338). 그래서 bash 가 폴링 폴백으로 들어가지 않는다. - rc 0(정상), 3(폴백), 1(lock 실패)의 의미는 그대로다.
- python PID 에만 직접 TERM 을 보낸 경우 python 은 0, bash 는
exit 0으로 끝난다. 수정 전(python 이 시그널로 죽고 bash 는 0)과 외부 결과가 같다.
- 시그널을 받은 구독자는 rc 3 을 내지 않는다(
- paho 스레드와의 충돌 없음:
- python 시그널 핸들러는 항상 메인 스레드에서 실행되고, 플래그와
threading.Event만 건드린다. - paho 스레드는 시그널을 처리하지 않는다.
loop_stop()은 메인 스레드에서 스레드를 join 한다.
- python 시그널 핸들러는 항상 메인 스레드에서 실행되고, 플래그와
- 테스트
test_sigterm_during_broker_handshake_takes_the_clean_path(:361-392):- 브로커 없이, 임의 포트에서 응답하지 않는 로컬 리스너를 쓴다.
connecting to로그 1 s 뒤에 TERM 을 보내고, rc 143,signal 15 received있음, 폴백 없음을 확인한다.- CONNACK 대기 5 s 중 1 s 시점이므로 약 4 s 여유가 있다.
connect전에 TERM 이 닿아도 로컬 연결은 곧바로 끝나 같은 결과가 된다. - 부하 때문에 로그 확인에서 TERM 까지 5 s 를 넘길 때만 실패할 수 있어, 결정적이라고 판단한다.
- grok 이 실패를 본
test_mqtt_loop_sigterm_exits_cleanly는 이번 실행에서 모두 통과했다. 이제subscribed to로그는 "핸들러 설치 이후" 를 뜻하므로, 테스트의 트리거 조건을 바꾸지 않은 판단이 옳다.
1-3. creator 가 새로 보고한 fork-exec 창 (고치지 않음. 후속 과제에 동의)
- 현상: TERM 이
&의 fork 와 exec 사이 수 ms 안에 오는 경우다. 자식이 물려받은 트랩이 전달된 TERM 을 삼키고, bash 는 트랩의wait에 묶인다. - 비차단으로 보는 이유:
- 기동 직후의 매우 좁은 창이다.
- 그렇게 살아남은 python 도 #19 의 자기 종료가 그대로 적용된다. 워크스페이스가 삭제되거나 lock 이 교체되면 5 s 안에 종료하므로, 샌드박스에서 무한 누수가 되지는 않는다.
- 후속 수정 때 함께 볼 점:
- 부모 bash 를 잃은 python 구독자(argv 는
python -)는 conftest 선정자(bash|sh <root>/…/reconcile.sh --subscribe)에도 세션 가드에도 보이지 않는다. - creator 가 제안한 방식(자식 생존 확인 → 제한 횟수 TERM 재전송 → 마지막에 KILL)에 동의한다.
- 부모 bash 를 잃은 python 구독자(argv 는
2. 브랜치 전체 검증 항목 (1)–(7)
| # | 항목 | 결과 |
|---|---|---|
| (1) | reconcile.sh |
✅ 아래 1번 목록 |
| (2) | daemon.py/lib.sh/cli.py |
✅ 134fe97 에서 바뀌지 않았다. 아래 2번 목록 |
| (3) | tests/conftest.py |
✅ 134fe97 에서 바뀌지 않았다. 아래 3번 목록 |
| (4) | 신규 테스트 결정성 | ✅ 아래 4번 목록 |
| (5) | 문서 | ✅ 아래 5번 목록 |
| (6) | 버전 락스텝 · 릴리스 커밋 | ✅ 아래 6번 목록 |
| (7) | 테스트 · 정적 검사 | ✅ 아래 7번 목록 |
reconcile.sh:- rc 계약:
set +e(:101) →&(:105) →_sub_pid=$!/wait/sub_rc(:379-381) →set -e(:383) 순서다. rc 가3일 때만 폴백하고(:385), 그 밖에는exit 0(:408)으로 끝나 종전 계약과 같다. - 트랩: 첫 자식 전(B1 수정),
&직후($!, R-1), 자식이 이미 끝난 뒤(kill/wait 가 아무 일도 하지 않음) 모두 rc 143 이다. _workspace_gone(:132-150):os.getcwd()예외를 처리한다(:138-141).(st_dev, st_ino)로 비교한다(:128-129,:145-147). 원래 lock 을 열어 둔 채이므로 그 inode 는 재사용될 수 없고, 교체 판정이 안전하다.
- paho 스레드와 충돌 없음: §1-2.
- 폴백:
trap … exit 143(:394) 과sleep & wait $! || true(:404-405). 여기의$!는 바로 앞&로 이미 값이 있으므로 B1 과 같은 문제가 없다. set -euo pipefail: B1 외에 의도치 않게 종료되는 새 경로는 없다.
- rc 계약:
daemon.py/lib.sh/cli.py:- 옵트아웃은 리터럴
1만 인정한다(daemon.py:28-31). - 옵트아웃 중에도 기존 데몬 PID 를 보고한다. stdout 은 비우고 stderr 에 한 줄 남긴다.
- legacy 경로(
lib.sh:2024-2027)가 python 본체와 바이트 단위로 같다.
- 옵트아웃은 리터럴
tests/conftest.py:- reaper 는 import 시점에 잡은
_REAL_POPEN(:16-26)으로 명령을 실행한다. - 선정자는 argv 형태와 스크립트 경로 전체로 대상을 고르고, 공백이 든 후보는 실제 파일일 때만 인정한다(
:32-58). - 3 s 유예 뒤 SIGKILL 로 넘어간다(
:86-97). mam_sandbox(:113)는 finalizer 를 먼저 등록해(:123) 맨 마지막에 실행되게 하고, 옵트아웃을 기본으로 켠다(:161).- 세션 가드는 basetemp 기준으로만 판정한다(
:1384-1398).
- reaper 는 import 시점에 잡은
- 신규 테스트 결정성:
- 테스트는 16건이다. 15개 함수에 파라미터화 1개(
:130, 2케이스)가 더해진 수로, 실제 수집 수와 같다. - 신규 2건은 임의 포트 리스너와 스크립트 원문 추출만 쓰므로 외부에 의존하지 않는다.
- 시그널 테스트 4건을 3회 반복했고 매번 모두 통과했다. 소요 시간 편차는 ±0.06 s 이하였다(§2 표).
- 포트 9 가정, 브로커 skip 조건,
from conftest import에 대한 판단은 지난 리뷰와 같다.
- 테스트는 16건이다. 15개 함수에 파라미터화 1개(
- 문서:
- monitor
SKILL.md:86-92의 핸들러 순서·폴백 설명이 코드와 일치한다. VERSIONS.md:47(항목 3)과:51(신규 테스트 16개, 수집 수와 같음)이 정확하다.- create
SKILL.md:71은 바뀌지 않았고 여전히 정확하다.
- monitor
- 버전 락스텝 · 릴리스 커밋:
lib.sh:32,lib_py/loop/eager.py:16이 4.4.5 이고,SKILL.md8개가 모두version: 4.4.5다.VERSIONS.md:9(현재 버전)와:23-(매트릭스),README.md:5,BOOTSTRAP.md:5,MESSAGING.md:3도 4.4.5 다.verify_framework_version을 통과했다.134fe97은 버전 값을 바꾸지 않았다.VERSIONS.md는 본문만 수정했다.- 릴리스 커밋
38d8ccb의 형식은 그대로다.
- 테스트 · 정적 검사: 아래 표.
| 테스트 · 정적 검사 | 결과 |
|---|---|
선택 실행 5파일 (issue19 16건, test_issue5_phase1_delegation, test_tier2_component, test_version_consistency, test_herdr_shim_contract) |
144 passed in 284.44 s, rc 0, skipped 0. 지난 라운드 142건에 신규 2건이 더해졌다. 브로커에 의존하는 2건도 skip 없이 실제로 실행되었다 |
시그널 테스트 반복 (-k "sigterm or handshake or sub_stop" ×3) |
매회 4 passed. 핸드셰이크 2.30–2.32 s, 폴백 SIGTERM 1.33 s, MQTT SIGTERM 1.16–1.22 s, _sub_stop 0.51–0.52 s |
데몬 누수 (정밀 필터: argv0 bash/sh + …/reconcile.sh + --subscribe) |
실행 전후 모두 실제 데몬 2개(8152, 35505)만 같은 PID 로 살아 있었다. 샌드박스 데몬 0개. B1 테스트가 띄운 sleep 30 잔여도 0개 |
| #15 레지스트리 가드 | 실행 전후 실제 .mam/jobs 레코드 목록이 같다(추가 0건) |
| 정적 검사 | bash -n(reconcile.sh, lib.sh) 통과. py_compile(tests/conftest.py, 신규 테스트) 통과. git diff --check 8e7282c...HEAD 클린. 작업 트리 클린, HEAD 134fe97 |
3. 비차단 권고
- 연결 중 SIGTERM 이 최대 약 5 s 늦게 처리된다 (N1 의 부작용):
- 실측(데몬 없이 python 단독): paho 2.1.0
connect를 응답 없는 주소(192.0.2.1,10.255.255.1)로 걸고 1 s 뒤 TERM 을 보냈다.- 핸들러는 1.00 s 에 실행되었다.
- 그러나
connect는 5.00 s 시간 초과까지 계속되었고, 프로세스는 TERM 뒤 4.0 s 에 끝났다.
- 수정 전에는 이 구간의 TERM 이 곧바로 프로세스를 죽였다(정리할 것은 없었다).
- 영향: 브로커 주소가 블랙홀일 때만 생긴다. conftest reaper 는 3 s 유예(
conftest.py:92) 뒤 SIGKILL 로 넘어가므로 회수는 된다. 다만 드물게 monitorSKILL.md:92의 "SIGKILLis never required" 문구와 어긋난다. - 개선안(선택): 연결 단계에서만 핸들러가 예외를 던지게 하거나, 문서에 "연결 중에는 최대 약 5 s" 를 적는다.
- 실측(데몬 없이 python 단독): paho 2.1.0
- fork-exec 창: §1-3 의 후속 과제.
- 폴백의 남는
sleep: 지난 권고를 유지한다. 문서화되어 있고, 간격(RECONCILE_POLL_INTERVAL) 안에 스스로 끝난다. _run_real시간 초과: 지난 권고를 유지한다. 시간 초과 시 자식ps/pgrep을 끝내지 않는다(conftest.py:19-26).
4. GM 참고
- HEAD 가 브리프의
38d8ccb에서134fe97로 옮겨졌다.- 2회차에서 agy·codex 의 PASS 와 grok 의 NOT PASS 는 모두
8f9745b까지만 본 것이다. 134fe97을 본 것은 creator 자체 검증과 이 리포트뿐이다. 만장일치 판정 전에 각 리뷰어가134fe97을 확인했는지 볼 것.
- 2회차에서 agy·codex 의 PASS 와 grok 의 NOT PASS 는 모두
- 이 저장소의 실제 데몬 PID 35505 는 수정 전 스크립트로 계속 돌고 있다. 재기동 여부는 오케스트레이터가 판단한다.
- 내 지난 리뷰(
9114ac86)에는 B1 누락이 있었다(§0-1).
5. 종합
- grok 의 N1(차단)과 B1 이 정확히 반영되었다.
- python 핸들러는 연결 전에 설치된다. 시그널을 받은 구독자는 폴백하지 않고 정리 경로(rc 0, bash 143)로 끝난다.
_sub_stop은 bash 3.2 에서 첫 자식 전에도 rc 143 으로 끝난다.
- 두 수정 모두 회귀 테스트로 고정되었다.
- 브리프의 7개 검증 항목을 브랜치 전체 기준으로 충족한다. 남은 사항(연결 중 약 5 s 지연, fork-exec 창)은 좁은 창이며 비차단이다. 설계 재검토는 필요 없다.
[VERDICT: PASS]