세션 데몬이 소유하는 것과 클라이언트가 각자 갖는 것의 경계, 그 경계를 표현하는
TerminalBackend trait, 살아 있는 세션에 설정을 다시 읽히는 경로, 그리고 백그라운드
worker의 종료 정책을 다룬다. 이 문서의 결정은 대부분 "표면이 여럿"이라는 하나의 사실에서
파생된다 — 한 세션에 attach한 TUI와 브라우저가 동시에 붙어 있을 수 있다.
TerminalBackend는 pane 추상화다. 구현체가 둘이고, 둘의 차이가 이 trait의 모양을 정했다.
trait TerminalBackend {
fn create_pane(&mut self, rows: u16, cols: u16, command: Option<&str>) -> Result<()>;
fn destroy_pane(&mut self, id: PaneId);
fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()>;
fn resize(&mut self, id: PaneId, rows: u16, cols: u16);
fn reorder(&mut self, order: &[PaneId]); // 기본 no-op
fn claim_size(&mut self); // 기본 no-op
fn drain_events(&mut self) -> Vec<BackendEvent>;
// Created / Output / Exited / Resized / SizeOwnership / Reordered
}PtyBackend(backend/pty.rs): portable-pty로 PTY를 만들고 reader 스레드가 출력·Exited를 채널로 푸시한다. 터미널 허브가 구체 타입으로 소유하며open_pane으로 id를 직답받는다 — 만든 즉시 등록해야 하기 때문이다.HubBackend(backend/hub.rs): 데몬 소켓 위에 얹은 같은 trait. 저장소당 하나이고 attach 연결을 공유한다. 아무것도 소유하지 않고 요청한다.
소유하지 않는다는 사실이 trait을 네 군데 바꿨다.
- pane은 반환값이 아니라 이벤트로 온다. id는 PTY가 실제로 사는 곳에서 나오고, 남이 연
pane도 같은 경로로 와야 한다.
create_pane은 "요청"이고BackendEvent::Created가 도착을 알린다. 이벤트가requested를 실어 내가 연 pane만 포커스를 가져간다 — 어느 pane을 보고 있는지는 클라이언트 각자의 일이다. 제목도 같은 규칙으로 큐에 대기했다 도착 시 붙는다. - 크기는 이 클라이언트가 정하는 것이 아닐 수 있다(아래 "PTY 크기" 참고).
Resized를 따라가고, 소유하지 않으면resize를 보내지 않는다. - 순서도 세션의 것이다.
swap_active_with는reorder요청이고,panes는Reordered가 투영하는 서버 canonical order다. - VT 에뮬레이션은 어느 쪽이든 클라이언트가 한다 —
PaneEmulator가 소켓에서 온 바이트를 PTY에서 온 것과 똑같이 먹는다. 뷰어에서 xterm.js가 서 있는 자리와 같다.
- Pane 생명주기 단일 owner:
drain_events는 보고만 하고 제거하지 않는다.Exited를 받은 쪽이destroy_pane을 호출해 PTY를 놓는다 — 클라이언트에서는TerminalState::poll, 허브에서는 워커 루프다. 허브가 그것을 빼먹어 스스로 끝난 pane의 master fd가 샜다(캡은 live pane만 세므로 열고 끝내기를 반복하면 무한히 쌓인다). - 닫기와 순서도 요청이다.
close_active는 pane을 그 자리에서 지우지 않고Exited를 기다린다 — 세션이 실행하지 않은 닫기(커맨드 큐가 꽉 찬 경우)가 있으면 프로세스는 살아 있는데 이 클라이언트만 그 pane을 영영 못 보게 된다. 남의 클라이언트가 닫은 pane이 오는 경로와 같다. - 세션이 시작 터미널의 이름을 준다.
[[startup_command]] name(없으면 커맨드 텍스트)이Created에 실려 모든 클라이언트가 같은 이름을 쓴다. 클라이언트가 직접 연 pane은 이름 없이 오고, 어느 쪽이든 OSC 0/2가 나중에 덮어쓴다.
무엇이 공유이고 무엇이 클라이언트별인지가 이 앱의 중심 결정이다. 전부 공유하면 브라우저에서 커서를 내릴 때 TUI 커서도 내려가 "디스플레이별 렌더링"이 의미를 잃고, 전부 로컬이면 같은 세션에 붙은 두 화면이 서로 다른 것을 보여준다.
- 공유(데몬 소유): 저장소 집합과 순서, 활성 프로젝트, 터미널 pane 집합·내용·순서·크기, 그리고 accent
- 뷰어 안에서만 공유(브라우저 간, TUI와는 공유 안 함): 사이드바 폭(
sidebar_width), 터미널 패널 높이(upper_pct). 둘 다viewer.json에 살지만 attach한 TUI는 읽지 않는다 — 앞은 TUI에 대응 값이 없어서, 뒤는 대응 값(config.layout.upper_pct)이 있어도 공유가 틀린 답이어서다 (web.md의 터미널 패널 높이 항목 참고). - 클라이언트별: 뷰 모드(status/log/tree), 커서·선택·스크롤, 포커스, fullscreen, 검색 텍스트
accent는 원래 클라이언트별이었다. 뒤집은 이유는 한 세션에 표면이 여럿이라는 사실이 그
편의보다 무겁기 때문이다 — TUI와 브라우저를 나란히 두면 같은 세션이 두 색으로 보였고, 어느
쪽이 이 세션의 색이냐는 물음에 답할 수 있는 값이 아예 없었다. 저장소별 색이 대신하던 "지금 어느
프로젝트인가"는 탭 이름과 활성 탭 강조가 이미 답한다. 값은 viewer.json 하나에 살고
(session/prefs), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은
그대로다. [theme] name은 아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다.
세션에는 문이 둘이다 — 브라우저의 HTTP 핸들러와 attach 소켓 — 그래서 브라우저에서 연 저장소는
attach 소켓의 아무것도 깨우지 않는다. watcher 스레드가 틱마다 세션을 다시 읽어 마지막으로 알린
것과 다르면 브로드캐스트한다. 알림(callback)이 아니라 관측인 이유: 알림은 나중에 추가된
mutation이 빼먹을 수 있고, 그 실패가 정확히 "브라우저 변경이 TUI에 안 닿는" 버그로 다시
나타난다. 그래서 브로드캐스트하는 곳이 하나이고, 새로 생긴 저장소의 터미널을 모든 클라이언트에
구독시키는 것도 여기다 — 소켓을 읽는 스레드는 read에 막혀 있어 할 수 없다. attach
클라이언트의 요청은 watcher를 즉시 깨우므로(Nudge) 키 입력이 폴링 간격을 기다리지 않는다.
세트를 보내는 곳도 watcher 하나다. 붙는 클라이언트도, 세트를 직접 물어본(ListRepos)
클라이언트도 자기가 보내지 않고 "아직 못 받았다"고 등록만 하고 watcher를 깨운다
(clients.rs의 owed_set). 한 큐에 생산자가 하나면 프레임 순서가 곧 상태가 바뀐 순서이기
때문이다. 전에는 attach 스레드와 watcher가 각자 보냈고, 둘 사이에 변경이 끼면 갓 붙은
클라이언트가 다른 모두가 떠난 상태에 남았다(watcher는 이미 "모두에게 알렸다"고 기록했다).
순서를 락으로 맞추는 대신 경쟁을 없애는 쪽이며, 뷰어의 preference 쓰기(serialWrite.ts)와 탭
순서 변경이 이미 같은 결론에 도달해 있다. 그래서 watcher를 띄우지 못하면 데몬은 시작하지
않는다(serve::start).
PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이다 — 자식은 들은 폭에 맞춰 그리고,
alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 없다. 그래서 tmux의
window-size latest와 같은 모델을 쓴다: 뷰어의 도착이 곧 소유권 이전, 이미 붙어 있으면
claim_size로 명시적 탈취(TUI <prefix> z, 뷰어의 "fit to this screen" 버튼), 소유자가 떠나면
남은 중 가장 최근에게, 아무도 없으면 마지막 크기 유지.
- 소유권은 hub별이 아니라 세션 하나가 갖는다(
session/size_owner.rs). 어느 repo가 앞에 있는지는 세션 공유라 "이 세션은 어느 화면에 맞춰져 있나"는 질문이 하나다. hub마다 따로 답하던 때는 탭을 옮길 때마다 붙어 있는 모든 페이지가 동시에 재접속해 소유권이 핸드셰이크가 늦게 끝난 쪽으로 갔다. - 뷰어는 커넥션이 아니다.
접속 = 소유자 도착은 소켓이 열렸다는 사실에서 의도를 읽어내는 것인데, 소켓은 사람이 앉는 것 말고도 열린다: repo 전환, 새로고침, 네트워크 끊김. 그래서 뷰어는 자기 이름을 대고(ViewerId— 브라우저는 탭당 id, attach한 TUI는 데몬 client id 하나로 모든 repo 구독을 묶는다) 방금 도착했는지를 직접 말한다. 브라우저는sessionStorage에 탭당 id를 두고(lib/viewerId.ts)/ws/term에viewer=로 실어 보내며, 페이지가 처음 뜨는 한 번만claim=1을 붙인다.localStorage가 아닌 이유는 그것이 탭별이 아니어서 한 브라우저의 두 탭이 한 뷰어가 되기 때문이다.viewer=가 없거나 형식이 어긋나면 서버가 일회용 id를 발급한다 — 거부가 아니라 이름을 대기 전의 동작으로 강등된다. - 해제에는 유예가 있다(
RELEASE_GRACE, 2초). repo를 옮기면 소켓 하나가 닫히고 다른 하나가 열리는데, 그 사이의 공백은 부재가 아니다. 유예를 끝내는 것은 hub worker의 tick(settle)이다 — 볼 사람이 있으려면 hub가 돌고 있어야 하므로 전용 타이머가 필요 없다. - 비소유자의 resize는 버려지고 실제 적용된 크기가 브로드캐스트된다 — 관전자의 에뮬레이터도 자식이 감는 곳에서 감아야 하기 때문이다. 소유자도 그것을 읽되("clamp됐다"를 그렇게 안다) "내가 요청한 값" 기록은 유지한다. 그러지 않으면 매 프레임 같은 clamp를 다시 요청한다.
- 입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 비소유 클라이언트가 곧 관전자여서 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다.
git status 한 번은 측정값으로 파일 260개 저장소에서 3 ms, 1만 개에서 23 ms, 5만 개에서
129 ms다. 1초마다 돌리면 아무 일도 없는 시간에도 그만큼을 태운다. 그래서 워크트리를
재귀 감시하고 변화가 있을 때만 읽는다. 옆의 트리 워처가 재귀 감시를 거부한 것과 다른
결론인데, 트리 뷰는 펼친 디렉토리만 필요해서 재귀가 낭비지만 status는 트리 전체가 대상이라 더
작은 감시 집합이 없다. 남는 위험(리눅스 inotify 디스크립터 소진)은 설치 실패 시 예전의 1초
폴링으로 폴백해서 받는다. 이 폴백은 끈적하다 — 실패 원인(watch 상한, 권한)은 1초 뒤에
달라지지 않으므로, 재시도는 아무도 안 보던 저장소를 다시 볼 때만 일어난다.
세 가지 상한이 이것을 안전하게 만든다:
- 읽기 간격 하한 1초 — 이벤트가 폭주해도 비용이 정확히 예전 폴링과 같고 절대 그보다 크지 않다.
- 10초 상한 — 이벤트를 놓쳤거나 트리 일부에만 감시가 걸렸을 때의 안전망. 감시가 아예 없으면 이 값은 쓰이지 않고 1초 폴링이 된다.
- git이 무시하는 경로는 읽지 않는다 — 빌드 산출물은 워크트리에서 가장 시끄럽고 status에
나타날 수 없는 유일한 것이다.
-f로 추가된, 무시 디렉토리 안의 추적 파일이 이것이 잘못 건너뛰는 유일한 경우이고 10초 상한이 잡는다.
아무도 안 보는 저장소는 걷지도 감시하지도 않는다(SnapshotChannel::watch). 데몬이 여는
워커는 처음부터 잠든 채로 시작한다(spawn_asleep) — 깨워서 만든 뒤 끄면 워커가 그 사이에
한 번 읽고, 그 낡은 값이 나중의 더 새로운 읽기 뒤에 발행된다. 워커는 순회를 끝낸 뒤에도
awake를 한 번 더 보고 잠들었으면 결과를 넘기지 않는다. 첫 구독자가 오면 다시 켜고 그 자리에서
한 번 읽어 답한다: 꺼져 있는 동안의 latest는 마지막 클라이언트가 떠날 때의 상태이고, 다음 날
아침에 연 페이지에는 그것이 낡은 값이 아니라 틀린 화면이다. /api/status도 같다. 이 켜고 끄기는
구독자 목록 락을 잡은 채로 결정한다 — 세었다가 놓고 등록하면 그 틈에 마지막 클라이언트가
떠나며 읽기를 꺼버려, 구독자가 붙어 있는데 아무도 다시 켜지 않는 상태가 남는다.
- 남은 한계: 워커가 큐에 넣은 읽기와 구독 시점의 즉시 읽기가 겹치면 오래된 쪽이 뒤에 발행될
수 있다. 다음 변화나 10초 안전망이 바로잡는다. 근본 해결(읽기마다 시각을 실어 발행 순서를 읽은
순서로 강제)은
SnapshotMsg가 TUI와 뷰어 양쪽에 걸쳐 있어 이 창의 크기에 비해 값이 크다. - git 디렉토리가 트리 밖에 있으면 그쪽도 감시한다.
git worktree add와--separate-git-dir은.git을 파일로 남긴다. 감시 대상은path()가 아니라 **commondir()**인데, linked worktree의path()에는 자기 index만 있고 ref는 본체 쪽에 있기 때문이다. 이 두 번째 감시는 저장소 핸들이 있어야 위치를 물을 수 있으므로 읽기 뒤에, 그리고 매 읽기마다 다시 확인한다(핸들은 주기적으로 다시 열린다). 감시를 새로 건 직후에는 읽기를 한 번 예약한다. 평범한 저장소는 두 번째 감시를 걸지 않는다 — 걸면 모든 이벤트가 두 번 온다. objects/logs/*.lock필터는 git 디렉토리 최상위에만 적용한다. 서브모듈 이름은 트리에서의 경로라 슬래시를 포함해modules/foo/objects/HEAD를 어떤 방법으로도 구분할 수 없다. 그래서 판단하지 않고 읽는다: 잘못 거르면 아무도 못 보는 변경이 생기고, 다 통과시켜도 서브모듈 fetch 중 초당 한 번 더 걷는 것이 전부다.- macOS는 이벤트 경로를 심링크 해석해서 준다(
/var/...→/private/var/...). 감시 디렉토리 경로를 canonical 형태와 원래 형태 양쪽으로 들고 비교한다 — 이걸 틀리면 정확성은 유지되지만 ignore 필터가 조용히 통째로 무력화된다. - 이벤트 큐는 한 번에 비운다. 읽기 한 번(5만 파일 129 ms) 동안 빌드는 수천 개의 이벤트를
쌓는데, 하나씩 소비하면 뒤에 도착한 종료 신호도 그 뒤에서 기다린다(
Drop의 join은 5 ms 상한이라 그대로 detach로 떨어진다). 깨어난 김에try_recv로 전부 받고, 이미 읽기가 예약된 상태(changed)면 경로마다 ignore 여부를 되묻지 않는다.
스크롤백 깊이는 두 상한이 만나는 자리다 — 허브는 pane당 바이트 링(256 KiB), 클라이언트는 줄(1000)로 센다. 평범한 출력에서는 클라이언트의 줄 상한이 먼저 차지만, 줄당 ~262바이트를 넘으면 리플레이가 줄 상한을 못 채운다(토큰마다 색을 바꾸는 하이라이팅이 거기에 닿는다). 그 지점을 테스트로 고정해 두고 상한은 바꾸지 않았다 — 거기 닿는 출력은 대부분 텍스트가 아니라 repaint 시퀀스이고, 상한은 저장소×pane마다 지불된다.
붙는 클라이언트에게는 기록이 아니라 상태를 준다(session/terminal/hub_modes.rs,
hub_repaint.rs, runtime/emulator/modes.rs). 바이트 링은 역사이지 스냅샷이 아니어서, 프로그램이
시작할 때 한 번 켜고 다시 말하지 않는 것들(alternate screen, 마우스 리포팅, bracketed paste,
DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이언트는 프로그램이 설정한 적 없는
터미널이 된다(스크롤·클릭 죽음, 화살표 인코딩 불일치, 붙여넣기 깨짐).
- 허브가 pane당 에뮬레이터를 모드 확인 용도로만 돌려(그리드는 읽지 않는다) 현재 모드를
PaneState에 적고,connect가 history보다 먼저PaneModes::prelude를 보낸다. 프렐류드는 12개 모드를 h/l로 전부 명시한다 — 받는 쪽은 xterm.js고 그 기본값은 이 에뮬레이터의 것이 아니다(1007이 실제로 다르다). - alternate screen pane은 링을 아예 replay하지 않는다: 그 바이트는 이 클라이언트에 없는
화면에 대한 셀 갱신이고, 전사는 프로그램 자신의 메모리에 있다. 대신 프로그램에게 다시 그리게
한다 — 크기를 한 행 줄였다가 tick 뒤에 되돌린다.
SIGWINCH는 크기가 실제로 바뀔 때만 가고, 두 resize를 연달아 하면 자식의 핸들러가 최종 크기를 읽어 "안 바뀜"으로 보므로(측정함: 같은$LINES가 두 번) 간격이 필요하다. 중간 크기는 클라이언트에 알리지 않고, 복원은 그 시점의 기록된 크기로 한다. pane당 최소 간격을 두어 소켓이 계속 끊기는 폰이 repaint를 반복시키지 못하게 한다. - 이 전제를 잃어 실제 사고가 났다 — 예전에는 붙자마자 오는 resize에 repaint를 기대고 있었는데,
리로드 플리커를 없애려 같은 크기면 resize를 생략하면서(web.md의 "PTY 크기는 확정된
값만 전달한다") 그 repaint가 사라졌다. 사용자가 깨진 화면에 누르는 복구 키가
Ctrl+L이고 fullscreen Claude Code는 그것을 2초 안에 두 번 받으면/clear를 실행한다 — 대화가 연달아 지워졌다.
입력의 출처를 기록한다(session/terminal/hub_diag.rs, session.rs,
viewer-ui/src/lib/clearKeyProbe.ts). 특정 사건 때문에 존재하는 계측이다 — 5초 사이에 대화가
14번 지워졌는데 0x0c가 30번쯤 기계적 간격으로 들어왔다는 뜻이고, 무엇이 보냈는지 알 수
없었다. nightcrow가 합성하는 입력은 스크롤·마우스 리포트와 plugin의 continue뿐이고 후자는 그
자리에서 로그를 남기므로, 남는 것은 클라이언트의 입력이다.
- 도착 기록 — 허브가
0x0c가 실린 입력 프레임마다 pane·client id·개수·동승 바이트 수·직전 프레임과의 간격·연속 구간 누계를 남긴다. 키보드에서 온^L은 혼자 오고 paste나 스크립트가 쓴 블록은 그렇지 않으므로 동승 바이트 수와 간격만으로 모양이 갈린다. 한 구간에서 40줄까지만 쓰고 나머지는 세기만 한다 — 눌린 채 반복되는 키는 초당 수십 번이라 로그가 스스로를 밀어낸다. - 출처 지문 — 브라우저가
0x0c를 보낼 때 그것을 만든 keydown의isTrusted·repeat·code·경과 ms를 함께 보고한다.isTrusted:false는 확장 확정,true+repeat:true는 물리적 키 반복, keydown 없이 온 바이트는 paste·IME·직접 주입이다. - 입력 내용은 어느 쪽도 기록하지 않는다 — 세는 것과 타이밍뿐이다. 보고는 클라이언트가 하는
말이므로 분당 상한을 두고,
code는 ASCII 영숫자 16자로 깎는다(줄바꿈이 들어오면 로그 한 줄을 위조할 수 있다). 원인이 특정되면 이 계측은 지운다.
config.toml을 고칠 때마다 데몬을 내렸다 올리면 살아 있는 pane이 전부 죽는다 — agent CLI가
작업 중이던 것까지. 그래서 두 테이블만 다시 읽는다. 무엇이 즉시 닿고 무엇이 안 닿는지는
"그 값을 이미 무엇에 썼는가"가 정한다.
[[plugin]]— 열려 있는 모든 프로젝트에 즉시. plugin은 pane이 아니라 자식 프로세스라 교체 비용이 세션에 없다. hub별로 diff한다(terminal/hub_reload.rs): 새로 원하게 된 것을 띄우고, 아닌 것을 멈추고,command/args/env가 바뀐 것만 프로세스를 갈아치운다.allowed_resume_flags·watch_on_signal만 바뀌면 살아 있는 자식을 건드리지 않는데, 그 둘은 판정마다 이쪽에서 읽는 값이고 plugin은 몇 시간짜리 대기 중일 수 있기 때문이다.[[startup_command]]— 이후에 여는 프로젝트부터. hub는 startup pane을 자기 수명에 딱 한 번 만든다(started: AtomicBool). 이미 열린 프로젝트가 그 목록에 쓴 pane은 살아 있는 자식이라 파일 편집을 근거로 교체할 수 있는 대상이 아니다. Catalog의 목록만 바뀌고 (catalog/config_tables.rs) 그 뒤rebuild가 띄우는 hub가 새 목록을 받는다.- 나머지는 재시작이 필요하다:
[web_viewer](리스너가 이미 바인드됨),[log], 그리고 클라이언트 소유인[layout]·[input]·[tree]·[mouse].
전송 계층에 독립적이다. session.rs와 같은 자리에 같은 이유로 둔다 — 브라우저는
POST /api/reload, attach한 TUI는 ClientMessage::ReloadConfig로 닿고, 둘이 같은 상태 변경에
착지해야 한다. 여기서 인증하지 않는 것도 session.rs와 같다(누가 물어볼 수 있는지는 각 전송이
정한다). 요청은 아무것도 실어 나르지 않는다 — 파일 자체가 요청이다. 내용을 실어 보내게 하면
클라이언트가 지어낸 설정으로 세션을 재구성할 수 있다.
절반만 적용되지 않는다. 파일 전체를 파싱·검증한 뒤에야 아무것이든 건드린다. 파일이 사라진
경우는 거부한다 — 시작 시에는 "아직 설정 없음"이 정상이지만 reload 시점에는 실수이고, 기본값으로
읽으면 파일을 지우고 reload하는 것이 모든 plugin을 조용히 멈추는 경로가 된다. --exec pane은
파일에 없으므로 Catalog가 따로 기억해 다시 병합한다(config::merge_startup_commands).
hub에서 무엇이 plugin을 원하는지는 그 hub의 opt-in으로 판정한다 — 새 파일의 것이 아니다.
편집으로 추가된 [[startup_command]]는 이미 뜬 hub에 pane이 없으니 그것이 가리키는 plugin을
띄우면 영영 아무것도 받을 수 없는 자식이 된다. 반대로 살아 있는 pane을 보고 있는 plugin은
아무것도 그것을 지명하지 않아도 유지한다: 살아 있는 agent 터미널을 조용히 감시 해제하는 쪽이
더 나쁘다. 멈추라는 뜻은 enabled = false이고 그건 따른다.
- pane의 opt-in은 host가 없어도 기록한다(
hub_plugins.rs의intended). 이것이 세션 중간에 plugin을 켰을 때 그것이 꺼져 있는 동안 만들어진 pane에 닿게 하는 유일한 경로다. 그 자체로는 아무 권한도 주지 않는다: pane에 실제로 작용하는 것은owners뿐이다. reload로 멈춘 plugin은 pane을 놓아주되 opt-in은 남기므로 끄고 다시 켜면 처음 켜는 것과 같은 자리에 착지한다. - 후계자가 뜨지 못하면 그 pane들도 놓아준다(
Plugins::abandon). 교체는 멈춘 plugin이 살아 있는 pane을 계속 붙잡는 유일한 경우인데 그 근거는 곧 후계자가 온다는 것뿐이다. spawn이 실패하면 host 없는 이름이 pane을 소유한 채 남고, 그 pane이 다음에 끝날 때 아무도 부탁할 수 없는 9일짜리 hold가 된다(is_inert인 hub는 만료 작업조차 돌지 않는다). - guard는 절대 재생성하지 않는다. relaunch 예산은 pane의 token으로 키를 잡는데, 그것이 exit마다
relaunch로 답하는 plugin을 묶는 유일한 상한이다. reload마다 새 allowance를 발급하면 그 상한에
영영 닿지 않는다 —
take_over가 spent budget을 그대로 두는 것과 같은 근거다. - relaunch hold는 그것을 쥐고 있던 자식과 함께 죽는다 — 교체든 정지든. 후계자는 hub에 아직
남아 있는 pane만 건네받는다(
start_host가titles로 걸러낸다). 그대로 두면 슬롯이 아무도 이행할 수 없는 9일 창을 끝까지 앉아 있는다. - plugin을 재시작하면 그 plugin이 진행 중이던 것은 사라진다. 상태가 그 프로세스 안에 살기
때문이다 —
nightcrow-recovery의panes: HashMap은 메모리뿐이다. host가 대신 경고할 수 없다: 살아 있는 pane에 대한 대기는 plugin 안에만 있고 host의pending에는 없다. 그래서 이 손실의 범위를 좁히는 것이spec_changed의 진짜 값이다. - 동시 reload는 직렬화한다(
SessionState::reload_lock). 두 클라이언트가 동시에 누르면 세션의 저장소들이 서로 다른 파일을 전달받은 상태로 남을 수 있다. - reload와 프로젝트 열기의 경합은 Catalog의 mutation lock이 막는다. 테이블 교체와 "알려줄
저장소 목록" 스냅샷을 같은 락 안에서 처리하고 그 목록을 호출자에게 돌려준다
(
set_config_tables가Vec<Arc<RepoEntry>>를 반환하는 이유). 없으면 같은 순간에 열린 저장소가 둘 사이로 빠져 열려 있는 내내 이전[[plugin]]테이블로 돈다.
답은 물어본 클라이언트에게만 간다 — reload가 하는 일은 다른 클라이언트 화면에 아무것도
드러나지 않으므로, 전부에게 알리면 자기가 하지도 않았고 볼 수도 없는 일에 대한 알림이 된다.
브라우저에도 화면 변화가 없어 toast가 피드백 전부다. 문구는 서버가 만든다
(ReloadReport::summary) — 같은 reload에 대해 TUI notice와 브라우저 toast가 다른 말을 하지
않도록. 닿지 못한 저장소는 보고에 드러낸다: 큐가 가득 찬 hub는 요청을 받지 못하는데, 막고
기다리면 그 하나 때문에 나머지가 전부 밀리므로 기다리지 않고 ReloadReport::unreachable로 세어
(1 was too busy to be told)로 덧붙인다.
백그라운드 worker(SnapshotChannel, CommitLogPagination, PtyPane)는 모두 "receiver/owner를
먼저 drop → worker가 다음 send 실패로 종료"라는 공통 종료 신호를 쓰지만, 호출 지점이 hot
path인지 quiescent moment인지에 따라 join 정책이 의도적으로 다르다. 리뷰 시 이 비대칭을
깨뜨리지 말 것.
- Hot path (UI 틱 안):
launch_commit_log_worker는 이전JoinHandle을 join 없이 drop한다. 매 prefetch마다 5 ms를 기다리면 스크롤이 jank해진다. worker 본체는tx.send1회 후 종료하므로 누적되지 않고, 받는 쪽(page_rx)을 먼저 drop했기 때문에 그 send는 즉시 실패한다. timed-join을 여기 추가하지 말 것. - Quiescent moment (Drop, repo switch, reply drain 직후):
cancel_commit_log_page_fetch,poll_commit_log_page_fetch의 reply drain 분기,Dropimpl은 모두try_timed_join(~5 ms)을 쓴다. 사용자가 클릭한 시점이거나 worker가 이미 마지막 syscall에 도달한 시점이라 UX 손실 없이 OS 스레드를 즉시 회수한다.
try_timed_join은 src/platform/threading.rs에 공유 helper로 두고 snapshot/commit-log/PTY 세
곳에서 호출한다. 새 worker 패턴을 추가할 때도 같은 분기 기준으로 join 정책을 고른다.