순진한 시나리오
FastAPI 의 request.is_disconnected() 로 아빠가 스트림 중에 stop 을 누른 걸 알아채고, adapter 를 우아하게 비우고, JSONL 에 'aborted' 표식을 깔끔하게 남길 수 있다고 생각하지. 틀렸어. 아빠가 잡아냈지.
실제로 일어나는 일 — SSE 제너레이터가 소비자일 때
클라이언트가 StreamingResponse 에서 끊기면 Starlette 은 ASGI task 전체를 취소해. 취소는 지금 멈춰 있는 await 에 CancelledError 를 쏘는데, 그게 거의 항상 adapter 의 __anext__() 야. 그래서 취소가 다음 반복의 is_disconnected() 확인보다 먼저 루프에 닿아. SSE 제너레이터가 곧 adapter 를 소비하는 route 에선 전부 이래 — 일곱 chat route 중 여섯이 아직 그렇게 지어져 있어.
disconnected플래그랑pippa_user_aborted표식 경로는 거의 안 돌아.- 취소 전에 이미 eager 하게 append 된 건 영속이야 — 아빠가 화면에서 흘러가는 걸 본 전부지.
- 취소 시점부터 턴이 자연스럽게 끝날 때까지 adapter 가 내보내려던 건 사라져.
그 route 들이 지키는 불변식
아빠가 화면에서 흘러가는 걸 본 건 전부 JSONL 에 있다. 'adapter 가 만들어낸 전부' 가 아니라.
healing 층 (Truth track, lesson 4) 은 JSONL 에 있는 것으로 재구성해. 그게 구조상 아빠가 본 것과 일치하니까, 시스템은 깨진 chat 을 절대 안 보여줘.
canonical route 는 대신 이렇게 해
Claude route 는 그걸 그냥 두지 않았어. 일을 둘로 쪼갰거든. 하나는 adapter 를 부르고, JSONL 이랑 SQLite 랑 다른 부수 효과를 전부 쓰고, SSE 줄을 큐에 밀어 넣는 파이프라인 task. 다른 하나는 그 큐를 브라우저로 넘겨주기만 하는 얇은 제너레이터. 연결이 끊기면 넘겨주던 쪽만 취소되고 나머진 멀쩡해. 파이프라인은 서버가 추적하는 백그라운드 스트림 묶음으로 떼어내져서 자기 끝까지 돌고, 전부 착지시켜. 서버가 꺼질 땐 그 스트림들한테 최대 30초를 주고, 그래도 남은 것만 취소해.
그러니까 그 route 에선 더 강한 불변식이 지켜져. 아빠가 마침 보고 있던 것만이 아니라, adapter 가 만든 전부가 착지해. 나머지 여섯 route 는 아직 이 분리를 안 받았어. Rule 2 를 거꾸로 읽은 거야. 조상이 키운 보장이 저절로 옆의 변형들한테 흘러가진 않아.