중지 버튼의 어려움은 화면을 멈춤으로 바꾸는 데 있지 않았다. 그 순간에도 파일 확인이나 대상 검증, 기록 저장은 기다리는 중일 수 있었다. 기다림이 끝난 코드가 예전 허용만 믿고 다음 행동을 하면 중지한 요청이 되살아난다. 그래서 북극성에서는 기다림 뒤에 실행의 주인이 여전히 같은지 다시 묻는다.
처음 검사만으로는 비동기 구간을 지킬 수 없다
ActionBroker.execute는 처음에 runEpoch와 running 상태를 검사한다. 하지만 그 뒤 prepared checkpoint를 저장하는 await가 있다. 저장하는 동안 사람이 제어권을 가져갈 수 있으므로 돌아온 직후 다시 check를 호출한다. 처음 통과한 함수라고 마지막까지 실행 권한이 고정되는 것은 아니다.
이 구조에서 runEpoch는 작업의 실행 세대다. 단순한 화면 새로고침 횟수도, 요청 성공 횟수도 아니다. 현재 세대와 호출이 갖고 온 세대가 다르면 STALE_RUN으로 거절한다. 같은 task ID가 남아 있어도 중지와 재개를 거쳐 권한이 달라진 경우를 구분할 수 있다. 오래 기다린 코드에 새 실행의 권한을 빌려주지 않은 것이다.
전달 직전 기록도 기다림이라는 사실을 놓치지 않는다
dispatch 함수는 dispatched checkpoint를 먼저 기다린 뒤 세대를 확인하고 내부 dispatched 값을 바꾼다. 기록을 남겼다는 것과 외부 행동이 실제 전달됐다는 것이 같은 사건은 아니다. 그 사이에 취소됐다면 기록 문구만 보고 전달을 계속해서는 안 된다.
나는 이런 경계를 읽을 때 await가 무엇을 기다리는지부터 표시했다. 디스크나 저장소가 느린 경우도 실행 수명이 바뀔 수 있는 구간이다. 네트워크만 비동기 경쟁의 원인이라고 보면 기록 저장 뒤에 난 구멍을 놓친다. 이 패턴은 모든 외부 효과를 원자적으로 취소하는 기술이 아니라 다음 전달에 앞서 오래된 실행을 거절하는 장치다.
중지는 세대를 바꾸고 남은 자원을 정리한다
TaskController.stop은 세대를 증가시키고 paused 또는 cancelled 상태를 남긴다. abort 신호, 대기 중 승인, worker와 작업 페이지도 각 경로로 정리한다. 상태 하나만 바꾸고 긴 대기와 입력을 방치한 구조가 아니다. DNS 확인 뒤에도 task ID와 세대를 다시 검사하는 별도 경계가 있다.
그렇다고 이미 서버에 도착한 저장을 되돌렸다고 표시하지 않는다. 실제 중지 요약에도 중단 이전 전달 행동은 되돌리지 않았으니 결과를 확인하라고 적혀 있다. 외부 효과가 남을 수 있는 중지와 실행 권한이 사라진 뒤 새 전달을 막는 일은 함께 필요하다. 둘을 섞으면 취소 뒤의 재전송 판단까지 잘못하게 된다.
반례는 검증이 끝나기 직전에 제어권을 바꾼다
관련 broker 시험은 verifyTarget 대역에서 runEpoch를 바꾼다. 그 다음 selectTab이 호출되지 않았고 STALE_RUN이 났는지 본다. 정상적으로 탭 선택이 되는 시험만으로는 확인하기 어려운 부분이다. 준비가 끝났다는 응답이 돌아와도 현재 권한이 없어졌으면 실제 전환을 하지 않는 것이 기준이다.
이번에는 원래 시험과 코드를 읽었으며 사용자의 실행을 중단하는 실험은 하지 않았다. 시험 대역이 확인한 탭 전환 경계를 모든 운영 동작의 즉시 중단 보장으로 확대하지 않는다. 공유된 차선 모듈은 오래된 소유자가 새 실행을 해제하지 못하는 조건을 따로 보여 준다. 실제 브라우저의 취소 전체를 대신하는 구현은 아니다.
취소 결과에는 남아 있는 효과도 적는다
내가 남기려는 구현 기준은 간단하다. 기다림 전에 얻은 권한을 기다림 뒤에 다시 확인하고, 오래된 결과는 현재 상태에 반영하지 않는다. 반면 이미 전달한 행동은 별도의 읽기 확인 대상으로 남긴다. 중지 요청, 실행기 종료, 외부 결과 확인은 서로 다른 근거를 가진다.
이 기준을 결제나 발행, 파일 교체 같은 업무에 옮길 때도 먼저 전달 경계를 찾아야 한다. 단순히 catch에서 cancelled를 반환하는 것으로 끝내면 사용자는 무엇이 남았는지 알 수 없다. 어느 대기 뒤 검사가 있었고 어느 행동은 이미 넘어갔는지를 설명할 수 있을 때 중지 기능이 실제 운영 도구가 된다.