당신의 개발 머신에서 지금 이 순간 CLI 세션이 몇 개나 살아 있습니까? 그리고 그중 가장 오래된 건 마지막으로 만진 게 언제입니까?
저는 상시 가동하는 맥 한 대에서 코딩 에이전트를 여러 개 굴립니다. 어느 날 그 머신에 tailscale ssh로 붙으려 했더니 프롬프트가 안 떴습니다. 출력 한 줄 없이 그냥 끊겼습니다.
$ tailscale ssh user@100.x.x.x
$ echo $?
10바이트, Exit status 1. 에러 메시지도 없습니다. 조용한 실패입니다.
네트워크는 무죄였습니다
의심 목록을 하나씩 지웠습니다.
- tailnet / ACL: 같은 대상에 명령 모드는 정상이었습니다.
tailscale ssh user@host -- 'whoami'→ 즉시 응답. - MagicDNS / 호스트키: 호스트명으로도 명령 모드는 됩니다.
- 버전 경고: 매번 뜨던
client 1.98.5 != tailscaled 1.98.9는 무관한 상시 노이즈였습니다.
즉 끊긴 건 대화형(PTY) 세션 하나뿐이었습니다. ssh -vv로 보면 PTY 요청과 shell 요청까지 서버가 수락한 뒤 0바이트로 즉사합니다. 여기까지 오면 네트워크·인증 계층은 다 통과한 겁니다. 문제는 그 위에 있습니다.
Tailscale로 상시 가동 맥을 원격 제어하는 글에서 "고아 포트" 함정을 다룬 적이 있는데, 이번엔 고아가 된 게 포트가 아니라 세션이었습니다.
진짜 원인은 서버 쪽 로그에만 있었습니다
클라이언트는 아무것도 안 알려줬습니다. 원인은 접속당하는 쪽 tailscaled 로그에 있었습니다.
ssh-session(...): start failed: pty.Open: device not configuredpty.Open 실패. 서버가 이 세션에 줄 가상 터미널(PTY)을 못 열었다는 뜻입니다. 직접 재현했습니다.
$ python3 -c "import os; os.open('/dev/ptmx', os.O_RDWR)"
OSError: [Errno 6] Device not configured: '/dev/ptmx'/dev/ptmx는 새 PTY를 하나 발급하는 장치인데, errno 6(ENXIO)을 냅니다. 발급할 슬롯이 없다는 겁니다.
$ sysctl kern.tty.ptmx_max
kern.tty.ptmx_max: 511 # macOS 기본 한도
$ ls /dev/ttys* | wc -l
527 # 이미 넘음
$ uptime
up 20 days, 26 users천장에 붙었습니다. 그럼 511개를 누가 다 쓰고 있느냐 — 이게 진짜 질문입니다.
터미널 탭이 아니었습니다
ps로 PTY(ttysNNN)를 물고 있는 프로세스를 훑었습니다. 터미널 앱 탭이 아니었습니다. 장수 CLI 에이전트 세션이었습니다.
claude14개 +codex3개, 각각 자기ttysNNN위에서.- 가장 오래된 것: 20일.
--resume으로 되살아난 세션도 섞여 있었습니다. - 각 세션은 원격 제어 릴레이(
~/.orca-remote/relay-*, cmux/Orca 셋업의 그 릴레이)가 띄운zsh -l안에서 돌고 있었습니다. - 릴레이는 버전이 서로 다른 게 4개 이상 동시에 살아 있었습니다. 낡은 걸 아무도 안 치웠습니다.
- 릴레이 하나가
zsh를 14~19개씩 붙들고 있어, PTY를 쓰는zsh만 79개였습니다.
폰이나 웹에서 붙었다 나간 세션이 서버 쪽에서 정리되지 않고 그대로 쌓인 겁니다. 20일 동안.
당신이라면?
20일 된 claude 프로세스 14개가 있습니다. 그냥 pkill -f claude 하시겠습니까?
그 목록 안에는 지금 이 정리 작업을 하고 있는 세션도 들어 있습니다. 릴레이 데몬도, 그 아래 살아있는 다른 작업도 섞여 있습니다. 넓은 pkill은 남의 작업을 죽입니다. 나이순으로 PID를 하나씩 골라야 합니다 — 그러려면 "이 세션은 죽었다"를 증명할 방법이 필요합니다.
죽은 세션과 살아있는 세션을 가리는 법
나이도 CPU 시간도 근거가 약합니다. 20일 된 세션이 지금도 쓰이고 있을 수 있고, CPU 시간은 백그라운드 keepalive로도 쌓입니다.
결정적인 신호는 **tty의 마지막 접근 시각(atime)**입니다.
$ ls -lu /dev/ttys003
crw--w---- 1 user tty ... Aug 11 09:24 /dev/ttys003 # 20일 전, 그 뒤로 I/O 0atime은 그 터미널에 마지막으로 읽기/쓰기가 일어난 시각입니다. 18~20일간 입출력이 한 번도 없었다 — 이건 방치된 세션이라는 확정 증거입니다. 대상 5개가 전부 이랬습니다.
그다음은 절차입니다. 릴레이 데몬은 건드리지 않고, 리프(claude/codex)부터 SIGTERM으로 정리했습니다. 세션 상태를 flush할 기회를 주려는 겁니다.
zsh -l은 SIGTERM을 무시합니다
리프를 죽였는데 PTY 카운트가 1밖에 안 줄었습니다(528 → 528에 가까움). PTY 슬레이브를 쥐고 있는 건 claude가 아니라 그 부모 zsh -l 세션 리더였습니다.
그래서 zsh를 죽이려 했더니 SIGTERM을 그냥 무시합니다. 로그인 셸은 SIGTERM을 트랩합니다.
맞는 신호는 **SIGHUP**입니다 — 터미널 창이 닫힐 때 실제로 날아가는 그 신호. zsh는 SIGHUP을 받으면 zshexit를 돌리고 정상 종료합니다.
kill -HUP <zsh_pid> # 5개 전부 여기서 종료. SIGKILL은 no-op였음.결과: PTY 528 → 523, 5슬롯 회수. /dev/ptmx도 다시 열립니다.
자가진단 3개
sysctl -n kern.tty.ptmx_max와ls /dev/ttys* | wc -l을 나란히 봐라. 두 숫자가 가까우면 다음 대화형 접속이 조용히 실패합니다.ps -Ao pid,etime,tty,command | grep -E 'claude|codex' | grep ttys— 며칠씩 된 세션이 몇 개 나옵니까? 그게 다 PTY를 하나씩 쥐고 있습니다.- 의심 가는 tty에
ls -lu /dev/ttysNNN— 마지막 I/O가 며칠 전이면, 그 세션은 당신을 기다리는 게 아니라 자리만 차지하고 있는 겁니다.
솔직한 부분
천장을 올리는 건(sudo sysctl -w kern.tty.ptmx_max=999) 응급 처치입니다. 재부팅하면 511로 돌아가고, 지속시키려면 LaunchDaemon이 필요합니다. 그리고 그건 근본이 아닙니다.
근본은 원격 제어 릴레이가 끊긴 세션을 회수하지 않는다는 것, 그리고 낡은 릴레이 버전이 정리 없이 계속 쌓인다는 것입니다. 손으로 PID를 골라 죽이는 건 고침이 아니라 분류(triage)입니다. 실패한 봇을 후회 없이 죽이는 법에서 프로세스만 죽이면 launchd가 되살린다고 썼는데, 여기서도 똑같습니다 — 리프만 죽이면 릴레이가 다시 붙일 수 있고, 부모 셸을 안 잡으면 PTY는 그대로입니다. macOS launchd 함정들의 연장선이기도 합니다.
지금 ls /dev/ttys* | wc -l 하나만 쳐보세요. sysctl -n kern.tty.ptmx_max랑 비교해서 가깝다면, 오늘 정리하는 게 맞습니다.