Skip to content

fix(phone-browser-mcp): reclaim tunnels/forwards leaked by SIGKILLed wrappers (#2529) - #2530

Merged
jinon86 merged 1 commit into
mainfrom
fix/2529-phone-browser-mcp-orphan-cleanup
Oct 26, 2026
Merged

jinon86 merged 1 commit into
mainfrom
fix/2529-phone-browser-mcp-orphan-cleanup

Conversation

@jinon86

@jinon86 jinon86 commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

요약

scripts/phone-browser-mcp.sh가 래퍼 SIGKILL 시 SSH ControlMaster 터널과 폰 adb forward를 남기던 누수(#2529)를 두 겹으로 회수합니다.

  1. 시작 시 고아 정리(주 방어선) — 세션마다 $STATE_DIR/<host>.<wrapper-pid>.<local-port> 스템에 .ctl(마스터 소켓)과 .state(host / adb serial / 폰 forward 포트)를 기록합니다. .state는 adb forward tcp:0 직후(로컬 포트 0 임시 스템)에 써서 SIGKILL 창을 최소화합니다. 새 래퍼는 자기 터널을 열기 전에 자기 host의 스템만, 명명 규칙에 맞는 파일만, 현재 uid 소유·심볼릭 링크 아닌 것만 훑고:
    • 래퍼 PID가 죽었거나, 살아 있어도 phone-browser-mcp.sh 래퍼가 아니면(PID 재사용 가드: /proc/<pid>/cmdline, 없으면 ps -o args=, 둘 다 없으면 보수적으로 "살아 있음") 고아로 판정 → ssh -S <ctl> -O exit(timeout 5s) + 소켓 삭제 + .state에 기록된 forward만 폰 adb로 forward --remove.
    • .state 없는 옛 소켓: 터널만 닫고 소켓 삭제, 폰 forward는 건드리지 않음(stderr에 기록).
    • .state는 원자적 rename으로 선점 후 처리 → 동시 sweep/reaper가 같은 forward를 두 번 지우지 않음.
    • 폰 연결 불가(ssh rc 255/timeout)면 .state를 남겨 다음 실행이 재시도하고, 그 sweep의 나머지 forward 제거는 건너뜀(한 번만 접속 시도).
    • 전체 시간 예산 PHONE_BROWSER_SWEEP_BUDGET(기본 20s, 0=끔), 모든 메시지는 stderr, 실패해도 실행은 계속.
  2. SIGKILL 감시(보조) — 터널이 열린 뒤 스크립트를 --reap-after <host> <pid> <lport> 모드로 다시 띄웁니다(setsid 있으면 별도 세션, stdin/stdout은 /dev/null, stderr는 $STATE_DIR/reaper.log, 256KB 넘으면 비움). PHONE_BROWSER_WATCH_INTERVAL(기본 3s, 0=끔)마다 래퍼 PID를 확인하고, 래퍼가 trap 없이 죽으면 같은 회수 로직을 실행합니다. 정상 정리 시 cleanup이 reaper를 종료하고 .state를 지우므로(이것만으로도 reaper는 종료) 남지 않습니다.
  3. 정상 cleanup도 ssh 호출에 시간 제한을 두고, 폰 연결 불가 시 .state를 남겨 다음 실행이 재시도합니다.

기존 동작/플래그/env(PHONE_BROWSER_KEEP_AWAKE, --mcp, 포트 선택, 보안 주석, stdout=MCP 전용)는 그대로입니다. 헤더 주석에 상태 파일·sweep·reaper 설명과 새 env 두 개를 추가했습니다. docs/·README에는 이 런처 언급이 없고, tools-cheatsheet.md는 매 세션 상시 주입 문서라 늘리지 않았습니다.

근거

  • 이슈 실측: ctl 소켓 12개 중 11개의 래퍼 PID가 DEAD, 고아 ssh 마스터 PPID=1, 폰 forward 56개 → trap이 돌지 않는 종료(SIGKILL 추정) 경로.
  • 이번 PR은 원인을 단정하지 않고, 어떤 종료 경로든 (a) 감시 프로세스, (b) 다음 실행 sweep이 회수하도록 했습니다. 실제 노드/폰에는 접속하지 않았습니다(가짜 ssh/adb/npx 하네스로만 검증).

테스트

  • bash scripts/phone-browser-mcp.test.sh → PASS=73 FAIL=0 (기존 39 + 신규 34)
    • (a) 죽은 PID + state → 마스터 닫힘, 기록된 forward 제거, 소켓+state 삭제 / state만 남은 고아도 회수 / sweep이 자기 forward 생성 전에 실행
    • (b) 살아 있는 다른 래퍼의 터널·state·forward 보존
    • (c) state 없는 소켓 → 터널만 닫고 forward 제거 없음
    • (d) PID 재사용 가드(살아 있는 sleep PID → 고아 판정)
    • (e) 래퍼 SIGKILL 후 reaper가 마스터·forward·state 회수, reaper.log 기록 후 종료 / 정상 종료 시 reaper 미잔존 / 별도 세션
    • (f) sweep 실행 시 stdout 비어 있음, reaper fd 0/1 = /dev/null
    • 다른 host·점 포함 host 접두사·심볼릭 링크·비소켓 ctl 미접촉, 폰 연결 불가 시 state 보존 후 다음 실행에서 재시도, PHONE_BROWSER_WATCH_INTERVAL=0이면 reaper 없음, 잘못된 env 값 rc 2
  • 변이 확인: sweep 호출 제거 → 12 FAIL, watcher 비활성 → 5 FAIL, PID 재사용 가드 제거 → 1 FAIL
  • bash -n 두 파일 OK
  • shellcheck --severity=warning -e SC2155,SC1090,SC1091 + --severity=error (CI와 같은 옵션) OK
  • python3 scripts/changelog_fragments.py check → changelog fragments ok (256 pending)
  • CCC_HARNESS_PHASE=static bash scripts/validate-harness.sh → HARNESS VALIDATION: PASS

트레이드오프 / 남은 위험

  • adb forward tcp:0가 돌아온 직후 .state를 쓰기 전 아주 짧은 순간에 SIGKILL되면 그 forward 하나는 기록되지 않습니다.
  • setsid가 없는 환경(일부 Termux)에서는 reaper가 래퍼와 같은 프로세스 그룹에 있어, 그룹 전체 SIGKILL이면 같이 죽습니다. 이 경우 다음 실행 sweep이 회수합니다.
  • 폰 adb 서버가 재시작돼 forward가 이미 사라졌고 같은 포트 번호가 다른 노드 세션에 다시 배정되면, 늦게 도는 sweep이 그 forward를 지울 수 있습니다. 폰 쪽 forward에는 세션 식별자가 없어 구별할 수 없고, 확률은 낮습니다.
  • 이미 쌓인 옛 forward(state 없음)는 설계상 정리하지 않습니다. 필요하면 운영자가 따로 정리해야 합니다.

Closes #2529

🤖 Generated with Claude Code

…wrappers (#2529)

Each session now records <host>.<pid>.<lport>.state (host, adb serial,
phone forward port) next to its ControlMaster socket. At startup the
launcher sweeps its own host's stems: a stem whose wrapper PID is gone, or
alive but no longer a phone-browser-mcp.sh wrapper (PID-reuse guard via
/proc/<pid>/cmdline with a ps fallback), gets a bounded `ssh -O exit`, its
socket removed and exactly the recorded phone forward removed. Sockets
without a .state only get their tunnel closed. Only uid-owned, non-symlink
files in the naming scheme are touched; .state files are claimed by atomic
rename and kept for a retry when the phone is unreachable. The sweep is
time-bounded (PHONE_BROWSER_SWEEP_BUDGET) and never fails the launch.

A reaper (the script re-run as --reap-after, under setsid when available,
stdin/stdout on /dev/null, stderr to $STATE_DIR/reaper.log) polls the
wrapper every PHONE_BROWSER_WATCH_INTERVAL seconds and reclaims the session
if the wrapper dies without running its traps; normal cleanup stops it.

Tests cover dead-wrapper reclaim, live-wrapper preservation, stateless
sockets, PID reuse, host/symlink isolation, unreachable-phone retry, the
SIGKILL watcher and clean stdout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jinon86
jinon86 requested a review from seoseo-ai as a code owner October 26, 2026 00:12

@seoseo-ai seoseo-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after exact-head validation and fresh operator authorization using the relay-held seoseo-ai credential.

@jinon86
jinon86 added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 3a0ed3d Oct 26, 2026
14 checks passed
@jinon86
jinon86 deleted the fix/2529-phone-browser-mcp-orphan-cleanup branch October 26, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

phone-browser-mcp: 세션 종료 후 SSH ControlMaster 터널·폰 adb forward 누수(래퍼 SIGKILL 시 정리 안 됨)

2 participants