연결 하나가 느려지자 나머지가 전부 멈췄다 · 먼저 입을 다물었을 뿐인데 답장까지 사라졌다 · 실습
대문자 인쇄소에서 사라진 답장을 복구한다
목표
송신 방향만 종료한 클라이언트에게 끝까지 답장하고, 메모리와 총수명을 제한하는 다중화 서버 구성요소를 만듭니다.
왜 중요한가
EOF에서 양방향을 닫으면 아직 보내지 않은 응답이 사라집니다. 반대로 무한히 기다리면 느린 손님이 연결 자원을 붙잡습니다. 입력 종료, 출력 잔여, 최종 사유를 나누어 두 문제를 함께 해결합니다. 실습은 연결당 EOF로 끝나는 한 요청과 ASCII 바이트 대문자 변환이며 HTTP나 TLS 구현이 아닙니다. 기존 줄 단위 서버는 수정하지 않고 별도 파일에 작성합니다.
단계
1. 종료 사유와 소켓 소유권을 나눈다 — Peer(sock, max_bytes=4096, expires_at=None)를 만드세요. max_bytes는 bool을 제외한 1~65536 int입니다. expires_at이 None이면 time.monotonic()+30.0으로 정하고, 직접 지정하면 bool을 제외한 음이 아닌 유한 int/float여야 합니다. 위반은 ValueError입니다. sock을 논블로킹으로 설정하고 sock·max_bytes·float(expires_at)를 보관하세요. inbox/outbox는 빈 bytes, read_eof/prepared/closed는 False, reason은 빈 문자열입니다. finish(reason)는 최초 호출에서만 closed=True, reason 보관, 두 버퍼 비우기를 수행하고 소켓은 닫지 않습니다.
2. EOF와 한도 초과를 구분한다 — Peer.read_once()를 구현하세요. closed 또는 read_eof면 recv 없이 0입니다. 나머지는 recv(min(4096, max_bytes+1-len(inbox)))를 한 번 호출합니다. BlockingIOError는 상태 유지와 0, 다른 OSError는 finish('io_error')와 0입니다. b''이면 read_eof=True와 0일 뿐 닫지 않습니다. 받은 바이트는 inbox에 붙이고 한도 초과면 finish('request_limit')합니다. 데이터가 있었으면 받은 길이를 반환합니다. expires_at은 바꾸지 않습니다.
3. 입을 다문 손님에게 답장을 만든다 — Peer.prepare_reply()를 만드세요. closed, EOF 이전, prepared=True이면 아무것도 하지 않습니다. 그 외에는 prepared=True로 표시하고 inbox.upper()를 outbox에 옮긴 다음 inbox를 비웁니다. outbox가 비어 있을 때만 finish('complete')합니다. 여러 번 호출해도 남은 답장을 다시 생성하지 않습니다. 문자열 디코딩 없이 bytes.upper()를 씁니다.
4. 실제로 나간 만큼만 지운다 — Peer.write_once()는 closed 또는 빈 outbox면 send 없이 0입니다. 그 외에는 outbox[:4096]을 한 번 send하고 반환된 길이만 앞에서 지운 뒤 그 길이를 반환합니다. BlockingIOError는 버퍼 유지와 0, 다른 OSError 또는 send 반환 0은 finish('io_error')와 0입니다. 전송 후 outbox가 비고 read_eof이면 finish('complete')합니다.
5. EOF 이후 읽기 관심을 끈다 — Peer.events()는 closed이면 0입니다. 나머지는 read_eof=False일 때 EVENT_READ, outbox가 있을 때 EVENT_WRITE를 OR한 int를 반환합니다. 소켓이나 버퍼를 바꾸지 않습니다. EOF 뒤 응답 준비는 반복문이 즉시 수행하므로 실제 등록 마스크 0은 종료한 연결에만 적용합니다.
6. 한 바이트로 수명이 무한 연장되지 않게 한다 — Peer.expire(now)는 closed가 아니고 now >= expires_at일 때만 finish('deadline')합니다. now는 호출자가 제공하는 유한 단조 시각입니다. EOF 뒤 답장이 남아 있어도 동일하게 적용하고, 이미 종료한 사유를 바꾸지 않습니다.
7. 등록을 해제한 다음 FD를 회수한다 — 모듈 함수 retire(selector, peer)는 selector.unregister(peer.sock) 후 peer.sock.close()를 수행합니다. 아직 등록되지 않아 KeyError가 나도 close하고, 그 외 오류는 close 후 다시 전파합니다. peer.reason을 바꾸지 않습니다. 유효한 열린 소켓이 입력이며 실제 close는 소유자인 반복문이 한 번만 호출합니다.
8. 침묵한 손님 옆에서 실제 TCP 답장을 완성한다 — serve(peers)를 만드세요. 입력은 서로 다른 열린 연결 소켓을 가진 새 Peer의 list이며 함수가 소켓 소유권을 받습니다. DefaultSelector로 모든 연결을 함께 처리하고 select 대기는 0.05초와 가장 가까운 마감까지 시간 중 작은 값(최소 0)입니다. 매 반복과 I/O 직전에 expire(time.monotonic())를 확인합니다. READ에서 read_once 다음 prepare_reply, WRITE에서 write_once를 호출하며 관심 마스크를 갱신합니다. 종료 연결은 retire로 제거하고, 예외가 나도 나머지 소켓과 selector를 회수합니다. 모두 종료하면 입력 순서의 reason list를 반환하며 빈 입력은 []입니다. 듣기·accept·스레드 생성은 이 함수에서 하지 않습니다. 채점은 4개 실제 TCP 연결과 앞 단계 계약을 재실행합니다.
참고
- mkdir -p /root/halfclose 후 /root/halfclose/server.py에 저장하세요. 설치나 외부 통신은 없습니다.
- python3 /opt/fixtures/halfclose/check.py 8 /root/halfclose/server.py로 종합 검사를 실행합니다. 8 대신 현재 단계 번호를 넣으면 앞 단계까지 누적 검사합니다.
- BlockingIOError는 아직 진행할 수 없다는 뜻이고 b''는 수신 종료입니다. recv(0)을 종료 검사로 쓰지 마세요.
- 완료는 로컬 출력 큐 소진이며 상대 업무 처리 보장이 아닙니다. 채점은 실제 수신 바이트도 별도로 확인합니다.
- 세션 종료 시 파일은 사라집니다. 필요한 코드는 종료 전에 별도로 보관하세요.
단계 8개
- 종료 사유와 소켓 소유권을 나눈다
- EOF와 한도 초과를 구분한다
- 입을 다문 손님에게 답장을 만든다
- 실제로 나간 만큼만 지운다
- EOF 이후 읽기 관심을 끈다
- 한 바이트로 수명이 무한 연장되지 않게 한다
- 등록을 해제한 다음 FD를 회수한다
- 침묵한 손님 옆에서 실제 TCP 답장을 완성한다