지피지기 백전백퇴

hmux - 열심히 짜던 프로젝트 갈아엎고 새로 시작하기


앞선 글에서 소개했다시피 최근에 agent 관리 목적으로 tmux를 수정하려다가, 삼천포로 빠져서 tmux를 Rust로 번역해보고 있다.

LLM Slop이 잔뜩 들어간 구현이라서 이게 과연 당당히 내놓을 만한 정식 프로젝트로서의 가치가 있나 의구심이 든다.

v0 대전략: 실패했지만 남은 게 있다

대전략 - 계속 고치다보면 언젠가 완성되지 않을까?

처음 생각했던 것은 tmux 짝퉁을 대충 만들고, tmux와의 차이를 발견하는 족족 고쳐나가면 언젠가는 tmux와 동일하게 동작하는 무언가가 나오지 않을까? 가 메인 아이디어였다. 일단 tmux와 동일한 프로토콜로 동작하는 서버를 만들고 나면,

  1. 동일한 client 동작에 대해 두 서버가 다르게 동작하는 테스트 케이스를 만드는 Agent
  2. 발견된 테스트 케이스를 고치는 Agent

두개를 계속 병렬로 돌리면? Profit! 이것이 바로 Loop engineering?!의 느낌으로 시작했던 것 같다.

왜 실패했나

초기 20~30% 레벨까지는 예상대로 굴러갔던 것 같다. 의외로 내가 사용하는 use-case를 충실하게 지원하는(즉, command 하나 없이 window 생성, split pane 정도의 기능만 구현한) 구현체가 몇번 loop 돌지도 않았는데 만들어졌고 거기부턴 생각하던 기능들을 덕지덕지 붙여나갔다.

그런데 문제는, 완성도가 올라갈수록 velocity가 낮아지더라는 점이다.

  1. tmux 호환성을 유지하기 위한 비용이 보이기 시작함 - 이를테면 처음에 libghostty-vt를 사용했었는데, 이후 이게 tmux랑 달라서 생기는 gap들에 대해선 고칠 방법이 없게 된다. 결국 v0 나중 버전에선 버리고 tmux에서 다시 가져다가 구현했었다.
  2. gap을 찾는 agent들이 동굴을 파고 들어감 - 의미있는 기능 격차를 발견하는 게 아니라, 지엽적인 flag 설정의 gap을 찾는다. 몇번 전체 command를 sweep하는 agent를 돌렸지만 그걸로도 충분하지 않았다.
  3. gap을 메우기 위한 구현이 조금 누더기처럼 보이기 시작했다 - 처음에 가정하지 않은 기능상 gap을 구현하기 위해, 의미없는 임시 flag를 추가한다던가 하는 식의 땜빵 구현이 늘어나게 되었다. Agent에게 일일이 구조부터 뜯어고치라고 하려니 내가 먼저 현재 구조의 문제를 알고 어떻게 고쳐야 하는지 지적할 수 있어야 하는데 slop으로 짠 코드에 그런 깊은 이해가 있을 수 있나…

무엇이 남았나

어쨌든 v0이 왜 실패하는지를 알았다는게 중요하다. 그 외에도…

  1. Agent 상태 확인 컨셉은 의도대로 동작한다는 것을 알았고, 해당 컨셉에 기반해서 만든 모니터링 툴은 현재도 사용하고 있다.
  2. tmux를 어떻게 써야 내가 원하는 기능을 제공할 수 있을지 확인할 수 있었다. 이를테면 처음에는 realtime notification을 별도 프로토콜 메시지로 구현하려고 했는데, 나중에 알고보니 이미 tmux에 프로토콜이 있다는 것을 알아 제거한 적이 있다.
  3. E2E를 기준으로 만들었던 테스트 케이스들은 그대로 남았다.

v1 대전략: 일단 tmux는 그대로 두고 뭘 추가해보자

이번에는 from scratch에서 뭔가를 만드는 게 아니라, tmux를 그대로 두고 그걸 점진적으로 Rust로 바꾸는 방향을 고민해 봤다. 그러다가 C2Rust를 발견, tmux를 그걸로 변환한 다음 unsafe 코드를 고치는 방향으로 시도해보기로 했다.

메모리 safety는 공짜로 주어지지 않는다

메모리 할당/해제를 거기에 대응하는 Rust의 문법으로 표현하는 과정에서, 자연스럽게 각 메모리의 lifecycle을 type과 곁들여서 추적할 수 있게 된다.

예를 들면, 처음에 malloc() / free()로 해제하는 메모리 공간들을, 처음에는 그냥 libc::malloc() / libc::free() 같은 식으로 기계번역을 한다.

그다음에는 할당 크기를 보고, 이게 Box일지, Vec일지 판단한다. 배열 말고는 동적으로 크기를 정할 일이 없기 때문에 LLM도 쉽게 할 수 있다. 할당한 메모리는 대개 into_raw()를 이용해서 바로 raw pointer로 변환, 기존 기계번역 코드에서의 변화를 최소화한다.

이후 모든 free 호출에 대해, 이게 실제로 어디서부터 할당된 메모리인지 추적해서, 원래 구조체로 되돌리고 drop_in_place를 수행한다. (그냥 drop해도 되는 경우가 많지만, 해당 필드가 어떤 구조체 안에 속해있고 이 구조체가 drop을 안하고 바로 libc::free()하는 경우, drop_in_place를 써서 메모리 해제 조치를 해야 한다)

drop_in_place는 계속 변환하다 보니 구조체도 자연스럽게 Drop을 적용받게 되고, 그에따라 자연스럽게 제거할 수 있었다.

메모리 safety를 얻다 보면 자연스럽게 기존 코드의 버그가 발견된다

위에서 적은 것과 같이 메모리 관리를 Rust 모델로 변경하다 보면 LLM이 자연스럽게 ‘이걸 이렇게 변환하면 tmux에서 발생하던 leak가 발생하지 않는 conformance gap이 발생합니다’를 찾을 수 있었고, 이것들은 당연히 고쳐야 하는 tmux의 memory leak이다.

tmux가 널리 쓰이는 것에 비해서는 의외로 잔잔한 leak이 여럿 발견되어서 사실 좀 신났다.

그래서 성공했냐

사실 모든 unsafe 코드를 제거하는 데 성공한 것인 아니다. 하지만 거기까진 어찌어찌 갈 수 있으리라고 생각한다. 아마 진짜 게임은 거기부터일 것이다…

  1. idiomatic Rust로 바꾼다고 코드가 갑자기 읽기 쉬워지는 게 아니다 - 현재 다수의 코드가 C style의 free function으로 작성되어 있고, 이것들을 적절한 trait으로 abstraction해서 알기 쉽게 만들 필요가 있다. 마음같아선 tmux 구현을 다시 가져와서 trait 단위로 differential test를 때리고 싶은데 가능할까 싶다.
  2. 아직 남아있는 global variable들이 많은데 이것들이 진정한 idiomatic Rust로 가는 길을 방해하고 있다는 인상이다.

결론

요새 LLM이 잘 해줘서 C 코드를 적당히 safe한 Rust로 변환하는게 그리 어려운 일은 아니다…? (과연?)