PTY REPL 멈춤: 띄운 지 1분 안에 write(2)에서 막힌 세션, 확인 순서와 우회법

PTY 기반 에이전트 세션에서 브라우저 자동화 CLI의 aside repl을 장기 실행한 뒤 응답이 끊긴다면, REPL을 더 만지기 전에 출력 경로부터 확인해야 한다. 2026년 10월 12~13일 운영 기록에는 REPL을 띄운 지 1분 안에 세션이 세 번 멈췄고, 메인 스레드가 write(2)에서 기다렸다고 남아 있다. 우회는 장기 REPL을 메인 세션 PTY에서 빼고 단발 실행이나 PTY 밖 FIFO 입력으로 돌리는 것이고, 커널 수준의 원인은 증명하지 못했다.

살아 있는 프로세스가 답하지 않았다

터미널 화면과 텔레그램 응답이 함께 멈췄다. 운영 메모에는 세 번 모두 메인 세션에서 REPL을 띄운 뒤 1분 안에 같은 정지가 나타났다고 적혀 있다. 한 번의 긴 작업이 아니라, 같은 실행 방식 뒤에 반복된 정지였다.

이 세션은 감시기(supervisor)가 띄운 PTY 안에서 돈다. 감시기가 PTY 출력을 계속 읽어 pty.log에 남겨야 세션이 다음 출력을 쓸 수 있다.

그 pty.log가 더 이상 진행하지 않았다. sample로 본 메인 스레드는 쓰기 대기 상태였고, 감시기 쪽 PTY에는 읽을 출력이 없었다. REPL을 종료해도 응답이 돌아오지 않아 세션을 재시작해야 했다.

이 세 가지는 서로 다른 관찰이다. 로그 정지는 출력이 진행하지 않는다는 신호이고, 스택은 어느 호출에서 기다리는지를 보여 준다. REPL 종료 뒤에도 풀리지 않았다는 사실은 자식 프로세스 하나를 끄는 것으로 복구되지 않았다는 뜻이다.

처음에는 감시기를 잘못 의심했다

이틀 동안 정지는 네 번이었다. 그중 세 번이 aside repl을 띄운 지 1분 안에 일어났고, 처음에는 이 연관을 보지 못했다.

첫 가설은 감시기가 빈 읽기를 한 번 만나면 출력 감시를 끊는다는 것이었다. 빈 읽기 뒤에도 감시를 다시 걸고, 그때마다 pty_eof 기록을 남기도록 고쳤다. 다음 정지 때 이 기록은 한 번도 남지 않았다.

두 번째 가설은 kqueue가 "읽을 출력이 있다"는 알림을 놓친다는 것이었다. 알림이 없어도 1초마다 출력을 직접 비우도록 바꿨다. 그래도 세션은 다시 멈췄고, 그때 감시기 쪽에는 읽을 출력이 없었다.

두 수정은 효과가 없어 모두 되돌렸다. 남은 것은 REPL을 띄운 시각과 정지 시각이 세 번 맞물렸다는 패턴이었다. 마지막 정지는 REPL을 띄우고 11초 뒤였다.

메인 프로세스가 정확히 어느 대상에 쓰다가 막혔는지는 관리자 권한 없이 확인하지 못했다. 그래서 운영 규칙만 먼저 바꿨다. 메인 세션의 백그라운드 PTY에 REPL을 띄우고 bash_input으로 스크립트를 보내는 방식을 중단했다.

최소 재현은 명령보다 연결 순서다

당시 실행 순서를 줄이면 다음과 같다. 실제 운영 세션이 아니라 재시작할 수 있는 별도 세션에서 확인할 절차다. 이 순서가 모든 환경에서 반드시 멈춤을 만든다는 뜻은 아니다.

  1. PTY 기반 하네스에서 bash의 run_in_background: true로 aside repl을 실행한다.
  2. 반환된 bash_id에 bash_input으로 REPL 입력을 보낸다.
  3. 세션 응답, 감시기의 출력 로그, 메인 프로세스의 스택을 함께 확인한다.

REPL에 보낼 입력은 원래 작업의 실행 조건을 보존해야 한다. 당시 스크립트 전문이 없는 상태에서 새 입력을 정답처럼 붙일 수는 없다. 위 절차는 문제의 연결 형태를 재구성한 것이고, 독립 실행 가능한 커널 버그 재현 코드는 아니다.

어떻게 감지할까

텔레그램이 답하지 않을 때는 브리지의 requests에 새 요청이 쌓이는데 대응하는 state가 생기지 않는지 본다. 그다음 감시기의 pty.log가 진행하는지 확인한다. 둘을 같이 보면 요청이 들어오지 않은 상황과, 들어온 요청을 처리하지 못하는 상황을 나눌 수 있다.

이 사례에서는 메인 프로세스의 sample 결과도 판단에 들어갔다. macOS에서는 sample <PID> 3 -file /tmp/stall-sample.txt로 3초 스택을 파일로 남긴다. 확인할 항목은 메인 스레드가 write에서 대기하는지, 감시기 쪽에 읽을 출력이 있는지, REPL을 띄운 시각과 로그 정지가 맞물리는지다. 셋 중 하나만으로 원인을 확정하지 않는다.

진단 기록에는 실행 순서와 정지 시각을 함께 남기는 편이 낫다. 재시작 뒤 화면이 움직인다는 결과만 남기면, 다음 장애와 같은 조건인지 비교할 근거가 줄어든다. 실제 복구는 감시기의 재시작 절차를 따른다.

우회는 오래 사는 REPL을 분리하는 것이다

바꾼 운영 규칙은 메인 세션 안에 장기 REPL을 두지 않는 것이다. 한 번에 끝나는 aside 명령을 쓰거나, REPL이 필요하면 PTY 밖에서 FIFO로 입력을 주고 출력을 로그 파일로 분리하는 기존 방식을 쓴다. 단순히 다른 에이전트에게 같은 PTY 실행을 넘기는 것으로 끝내지 않는다.

단발 실행에서는 stdin을 닫고 종료 기한을 둔 형태로 작업을 나눌 수 있다. 아래는 입력 전달과 제한 시간 설정의 예시다. macOS에는 timeout이 기본으로 없어서 Homebrew coreutils의 gtimeout을 쓴다. 해당 aside 버전이 스크립트 인자를 받는 환경을 전제로 한다.

gtimeout 60s aside repl "console.log('probe')" </dev/null

60초는 예시 값이다. 실제 작업은 자신의 CLI 사용법과 완료 조건에 맞춰 한 번 실행하고 끝나는 단위로 바꾼다.

하네스의 bash에 있는 timeout과 셸의 gtimeout(timeout) 명령도 구분해야 한다. 로컬 터미널 도구 문서상 백그라운드 세션은 bash의 timeout으로 종료되지 않는다. run_in_background: true에 숫자만 넣었다고 장기 REPL의 수명이 제한된 것으로 보면 안 된다.

stdin을 닫고 기한을 두는 조치는 입력 대기와 실행 시간을 다루는 방법이다. 이미 막힌 메인 세션을 풀어 준다는 보장은 없다. 실제 기록에서도 REPL 종료만으로 복구되지 않았으므로, 우회 실행과 장애 복구를 다른 단계로 둔다.

결론: 멈춘 위치와 근본 원인을 구분한다

세 번의 반복과 쓰기 대기는 실행 방식을 바꿀 근거가 됐다. 하지만 PTY 버퍼, 터미널 상태, 커널의 어떤 경로가 원인인지 증명한 결과는 없다. 여기서 말할 수 있는 범위는 이 하네스의 PTY 안에서 장기 REPL을 돌린 뒤 정지가 반복됐다는 것까지다.

결국 기준은 그럴듯한 원인 이름이 아니라 다시 확인할 수 있는 증거다. 같은 증상이 보이면 요청 상태와 출력 로그, 메인 스레드의 대기를 먼저 남기고, 장기 REPL을 단발 실행이나 PTY 밖 입력 경로로 분리한다.

다음 글

댓글

이 블로그의 인기 게시물

AI 코딩 스킬, 많을수록 좋을까 — 자산이 부채로 바뀌는 손익분기

M4 Max 128GB 로컬 LLM 4종 속도 비교 — 실측 기록과 한계

Opus 5는 더 잘 쓰고, 더 AI답게 쓴다 — 세 모델 첫날 실측