-
Notifications
You must be signed in to change notification settings - Fork 0
Design Tradeoffs and Investigations
이 페이지는 pytmux 개발 과정에서 수행한 주요 설계 트레이드오프 분석과 현상 조사를 한곳에 모은 것이다. 각 절은 (1) 설계 질문 또는 증상, (2) 분석/실험, (3) 저울질한 트레이드오프, (4) 결론/판정 순으로 정리한다.
pytmux의 서버는 원시 PTY 바이트 스트림을 셀 그리드로 변환하기 위해 터미널 에뮬레이션 엔진 pyte(0.8.2)를 쓴다. 이는 멀티플렉서의 본질 기능이라 결합이 깊다. "pyte 대신 VT 파서를 직접 짜면 장단점이 무엇인가?"가 질문이었다.
pytmuxlib/model.py(약 1230 LOC)를 정독해, pyte 위에 얹힌 우회/확장 코드를 성격별로 정량화했다.
| 분류 | 내용 | 라인 수 | 동인 |
|---|---|---|---|
| feed 전 바이트 재작성 | 콜론식 SGR 정규화(4:0·38:2::r:g:b), alt-screen 전환 가로채기, 청크 경계 미완성 CSI 이월, 사설 DCS 제거, XTMODKEYS/kitty-keyboard 제거 |
~111 | pyte 0.8.2 파서의 버그·미지원을 먹이기 전에 고침 |
| 화면 상태 동작 확장 | Back-Color-Erase 보정 + autowrap 가드, 경량 스크롤백 화면, 셀 속성 → SGR 이스케이프 환원(재시작 스냅샷 색 보존) | ~264 | pyte.Screen 서브클래싱 |
| 성능 계층 | 무효 중간 프레임 드롭(코얼레싱) | ~30 | 순수 파이썬 feed가 리페인트 버스트보다 느림 |
model.py의 약 1/3이 pyte를 보완/우회하는 코드였다. 핵심 통찰: "파서를 바꿔서 없앨 수 있는 것"은 ~111 라인뿐이고, 나머지(화면 모델 거동/직렬화)는 토크나이저 교체로는 사라지지 않는다.
| 옵션 | 무엇을 | 제거되는 우회 | 위험 | 통제 이득 |
|---|---|---|---|---|
| A. pyte 벤더링 + 파서 패치 | 콜론 SGR·alt-screen을 CSI 파서에서 직접 처리 | ~111 | 낮음 | 높음 |
| B. 토크나이저만 자작 | 바이트→이벤트 프론트엔드만 교체, pyte Screen 연산은 유지 | ~111 | 중 | 높음 |
| C. 풀 자작 VT 에뮬레이터 | 파서 + 화면 모델 전부 | ~375 | 높음 | 최대 |
| D. 성능만 최적화 | Cython/배칭 강화 | 없음 | 낮음 | 성능만 |
현재 고통의 대부분은 파싱 문제지 화면 모델 문제가 아니다. 풀 자작(C)은 검증된 화면 상태 로직(스크롤영역 DECSTBM·origin mode·탭스톱·문자셋·pending-wrap·wide-char/grapheme 폭 등 수십 년 누적 정확성)을 가장 핫한 경로에서 다시 짜야 해, 264라인 흡수 이득보다 위험이 압도적이다.
자작 증분 토크나이저 pytmuxlib/vtparse.py(VTTokenizer)를 라이브 feed 경로와 분리한 채 구현했다. pyte의 dispatch 테이블과 Screen 메서드는 재사용하고 증분 FSM만 자작해, 우회 4종(콜론 SGR·사설 CSI 드롭·청크 경계 carry·alt-screen 라우팅)을 상태기계의 정상 전이로 흡수했다.
검증 방식이 핵심이었다. tests/test_vt_parser_equivalence.py는 native ≡ pyte 동등성을 render·셀 SGR 속성(fg/bg/bold/italics/underscore/reverse/strike)·임의 슬라이싱(1~997바이트)·스크롤백·실제 캡처 픽스처에 대해 상시 검증한다.
배선 중 발견한 버그가 검증 방법론의 교훈을 남겼다. _dispatch_csi의 priv in "<>=" 검사가 빈 마커 priv==""에 대해 항상 True였다(파이썬에서 "" in s는 언제나 참 — 부분문자열 매칭). 그 결과 마커 없는 정상 SGR(\x1b[1m 등)이 전부 드롭돼 굵게·색이 사라졌다. 단일 문자 튜플 멤버십(priv in ("<", ">", "="))으로 교정했다.
근인은 PoC의 차분 비교가 텍스트(screen.display)만 봤다는 점이었다. → 회귀망을 셀 SGR 속성까지 비교하도록 강화. 교훈: "텍스트 동일"은 "화면 동일"이 아니다 — 속성까지 봐야 한다.
성능도 실측했다. 5MB 합성 워크로드에서 현 파이프라인은 이미 pyte-raw 수준 처리량이었고(빠른 경로 + 코얼레싱 덕), 자작 토크나이저는 plain-text 런 fast-path(정규식 배칭)를 넣으면 pyte와 동등~약간 빠름이었다. 즉 성능은 동인이 아니며 옵션 D는 불요다(fast-path 없는 char-by-char 초안은 ~10% 느렸음 → 배칭이 핵심).
- 권고 = 옵션 B(토크나이저만 자작). 우회 핵 ~111 라인과 청크 경계 정규식 취약성을 없애면서, 위험한 화면 상태 로직은 검증된 pyte를 그대로 둔다.
- 옵션 플래그(
vt_parser:pyte|native) 뒤에 라이브 배선했고, 합성 동등성·별도 테스트 서버 실프로그램 검증(seq | lessalt-screen 진입/복귀, kitty/XTMODKEYS 드롭, 콜론/트루컬러 SGR)·작업 보존 재시작을 거쳐 코드 기본값을 native로 전환했다. pyte는vt_parser="pyte"opt-in으로 유지. - 의도된 거동 차이: native는 generic DCS 본문을 소비(드롭)한다 — pyte가 잔해로 흘리던 것이 사라지는 개선.
- 잔여 작업: native 안정 후 우회 ~111줄 삭제(단, 삭제 시 pyte opt-in 경로도 함께 사라지므로 폴백 유지 여부는 별도 결정).
pytmux에는 두 개의 키 모드가 있다. "prefix 키와 ESC 모드를 하나로 통합할 수 있는가?"가 검토 요청이었다.
| 항목 | prefix 모드 (_handle_prefix) |
ESC 모드 (_handle_esc_mode) |
|---|---|---|
| 진입 |
prefix_key(기본 ctrl+b) 1회 |
ESC 또는 백틱 |
| 생애 | one-shot — 키 1개 처리 후 즉시 복귀 | sticky — 방향키 등으로 계속 머무름 |
| 성격 | tmux 호환 명령 키맵(풀 보캐) | 마우스리스 포커스 내비게이션(탭바·헤더·상태바 포커스 링) |
같은 키가 두 모드에서 다른 동작을 하는 것이 통합의 핵심 장애물이다.
| 키 | prefix | ESC | 충돌? |
|---|---|---|---|
| 방향키 | select-pane(이동) | select-pane(이동, 모드 유지) | 같음(생애만 다름) |
: |
명령 프롬프트 | 명령 프롬프트 | 같음 |
n |
next-window | new-window(새 탭) | ⚠ 충돌 |
p |
prev-window | new-pane(상하 분할) | ⚠ 충돌 |
x |
kill-pane | 탭바 포커스 시 kill-tab | 의미 다름 |
n/p의 의미가 정반대 계열(탭 순회 vs 생성/분할)이라, 단순 합치기는 둘 중 하나의 근육기억을 깬다.
-
옵션 A — 완전 통합(단일 모드) ❌: one-shot과 sticky를 하나로 못 둠.
n/p충돌을 한쪽으로 강제하면 반대쪽 사용자가 깨짐. prefix의 풀 보캐를 sticky로 옮기면 방향키 연속 내비 중 오타 1키로 파괴적 액션(kill-pane 등) 위험. → 표면 단순화 이득 < 사용성·안전 손실. -
옵션 B — 'sticky prefix' ⚠: prefix를 머무르게 함. tmux
prefix=1회 1명령근육기억을 깨므로 반드시 설정 토글(기본 off)로. -
옵션 C — 공유 디스패치 + 두 진입점 유지 ✅: 진입 키·생애는 그대로 두고, 동일 의도 액션(select-pane·select-window·command-prompt·split 등)을 하나의 함수 테이블로 모은다. 각 핸들러는 키→의도 매핑만 갖고(
n/p의 모드별 차이를 여기서 흡수), 공통 의도는 같은 구현을 부른다. 사용자 표면 0 변화, 중복 로직만 제거, 위험 낮음(순수 리팩토링이라 기존 테스트가 회귀 가드). - 옵션 D — 부분 흡수 ➕: ESC 모드에 충돌 없고 비파괴적인 prefix 명령(zoom·layout 등)만 추가. C와 병행 가능.
완전 통합은 권장하지 않는다. 두 모드는 진입 철학·생애·키 의미가 달라, 합치면 한쪽 사용성을 반드시 깬다.
권고 로드맵:
- 옵션 C(공유 디스패치)로 내부만 통합 — 표면 무변화, 중복 제거.
- (선택) 옵션 D로 ESC 모드에 비파괴 prefix 명령 소수 흡수.
- (옵트인) 단일 모드를 원하면 옵션 B를
sticky-prefix설정으로(기본 off). -
n/p충돌은 해소하지 않는다 — 각 모드 관습을 유지하고, 통합층은 '의도' 레벨에서만 공유한다.
pytmux pane 안에서 LLM 코딩 도구를 실행하던 중, 그 도구가 멈추고(halt) 동시에 pytmux 클라이언트(Textual 앱)도 종료되는 재현성 있는 현상이 보고됐다. 초기 가설은 "서버가 죽어 연쇄 종료"였다.
여러 차례 스냅샷을 ps로 실측한 결과, 핵심 사실이 드러났다.
- 서버는 죽지 않았다 — 매 스냅샷에서 PPID=1 데몬으로 20시간 이상 무중단 생존.
- pane의 셸(zsh)들도 죽지 않았다 — 기동분 그대로 생존.
- 죽은 뒤 재기동된 것: 클라이언트 + 일부 pane 안의 도구 프로세스. 다른 pane의 같은 도구는 생존.
- OS 크래시 리포트 없음(python/node 항목 0건) → segfault 아님.
- jetsam/메모리 압박 로그 0건, 메모리 79% 여유 → OOM-kill 아님.
- 서버 측 예외 로그(
.error.log) 부재 → 서버 핸들러 경로에서 잡힌 예외 없음.
이는 가설을 차례로 반증했다. 서버가 죽으면 PTY master fd가 닫혀 pane의 foreground 프로세스에 SIGHUP이 가야 하는데, 셸이 살아있다는 것은 PTY가 닫히지 않았다는 뜻이다.
도구를 죽이는 의심 경로(auto-action 주입)도 정독으로 소거했다. pytmux가 보내는 키는 continue·/clear·/compact·shift+tab·피드백 dismiss 등뿐이고 Ctrl-C/Ctrl-D/kill 신호는 없었다. 게다가 당시 설정상 자동 주입 옵션이 전부 꺼져 있었다.
"서버 사망"은 매력적인 단일 설명이었지만 실측 데이터로 뒷받침되지 않았다. 그것은 "서버가 죽으면 이렇게 된다"는 메커니즘 가설일 뿐, 서버는 한 번도 죽지 않았다. 시간 상관(클라이언트와 도구가 ~7초 간격, 데스크톱 앱 자동업데이트와 같은 분대)은 공통 외부 트리거를 시사하나 상관이지 인과 확정이 아니다.
동시 종료의 진짜 그림은 거의 동시에 겹친 두 개의 독립 사건이었다.
- (A) 클라이언트의 독립 종료: 배후에 서버 restart가 없었다(opts·소켓 mtime이 사건 시각과 불일치). 따라서 클라이언트는 Textual 미처리 예외 크래시 또는 사용자 수동 재실행으로 자체 종료했다. 결정적으로, 당시 클라이언트는 크래시 트레이스백을 디스크에 전혀 남기지 않아(excepthook/크래시 로그 부재) Terminal 스크롤백(휘발)으로만 가 사후분석이 불가능했다 — 조사 범위 안에서 가장 실질적인 약점.
- (B) pane 안 도구의 독립 종료: pytmux 자동 주입 탓 아님(설정상 면책), CLI 자동업데이트 아님(바이너리 mtime 불변), segfault·jetsam 아님, 셸이 시그널 종료 메시지를 안 찍음(클린 exit). → 도구 내부/외부신호 영역으로 pytmux 통제 밖.
결론: 이 "동시 종료"는 서버 사망의 귀결이 아니라, ① 자동복구·크래시로깅이 없는 클라이언트의 독립 종료와 ② pytmux 통제 밖 도구 프로세스의 독립 종료가 거의 동시에 겹친 것이다. 서버·셸은 멀쩡했다.
원인이 통제 밖이라, "재발 시 즉시 원인을 잡을 수 있게" 하는 관측성 + 복원력 위주로 코드를 보강했다.
-
클라이언트 크래시 영속화 + 자동복구(
client.pyrun_client):app.run()을 try로 감싸 미처리 예외를<sock>.client.crash.log에 기록. 크래시 시 새 클라로 자가 재기동(execv) — 서버 생존이라 즉시 재attach해 화면 회복. 연쇄 크래시 가드(30초 내 재크래시 5회 초과 시 자동복구 중단)로 무한 루프 방지. -
서버 종료 시그널 핸들러(
serverio.py): SIGTERM/SIGHUP에 핸들러 설치 — 수신 시.error.log에 기록 후 깨끗이shutdown(). 외부 kill 여부를 다음 조사에서 판별 가능하게. -
run_server미처리 예외 광역 가드(server.py): 데몬 stderr=/dev/null이라 평소 흔적 없이 사라지던 '서버 사망' 변종의 트레이스백을.error.log에 확보.
이후 한 "pytmux가 terminated 되었다"는 신고는, 실측 결과 로컬 pytmux는 죽지 않았고 실제 실패한 것은 원격 호스트로의 remote-attach(SSH 인증)였다. <sock>.client.crash.log 부재가 "클라이언트 미크래시"를 입증한 첫 실사례였다(§3.5-1의 크래시 영속화가 부재로 증명).
이때 정립한 진단 체크리스트:
- 프로세스/포트 생존 확인.
-
<sock>.client.crash.log존재? 있으면 클라 크래시, 없으면 미크래시. -
<sock>.error.logtail:remote_attach(비치명·무시 가능) vsrun_server(fatal)구분. - 화면이 "서버가 종료되었습니다"였으면 = 서버가 보낸 "bye"(마지막 세션 닫힘/kill) 경로이지 크래시가 아님.
remote-attach 실패는 serverremote.py가 OSError/ConnectionError/TimeoutError를 잡아 notice로 알리고 return False하는 잡힌 비치명 경로라, 로컬 서버/클라를 종료시키지 않는다.
소개 · 사용
- Project-Overview
- User-Manual
- Screenshots
- Settings-Popup
- Single-Session-Model
- Tmux-Feature-Comparison
플러그인
Claude Code 플러그인
- Claude-Code-Plugins
- Dev-Guide-claude-code
- Dev-Guide-claude-token-usage-view
- Dev-Guide-claude-prompt-history
- Dev-Guide-claude-resume
- Dev-Guide-claude-disable-feedback
플랫폼 · 성능
품질 · 보안
리뷰·분석 보고서
연혁
기여