diff --git a/.claude/README.md b/.claude/README.md index 0b608a5..f4fd233 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음. **[2026-08-21 확장] 확정된 결정의 "왜 그렇게 정했나" 근거 기록도 여기 둔다** — `research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니고, `base/`가 근거로 인용하는 온디맨드 자료라는 이 폴더의 기준에 정확히 맞기 때문(`slot-attach-decomposition.md`/`epoch-brand-composition.md`가 그렇게 들어옴) | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). `pre-implementation-handtrace-round7.md`(**[2026-08-25 신설, 회신 대기]** 7라운드: 6라운드와 같은 손 트레이싱이되 범위가 **M2(반응형 코어)와 M2→M3 경계**다. 패스 4개가 각기 다른 각도를 쓴다 — 1차는 프리미티브 사이의 *호출 순서*를 시간축으로 겹쳐 보기, 2차는 문서가 "확인했다"고 적은 런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어보기, 3차는 **커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려보기** + 1·2차가 뺐던 "M2를 *소비하는* 문서", 4차는 **M2 코어를 문서 그대로 옮긴 참조 구현을 돌려보기** + 아무 문서도 안 정한 **예외 경로**. 발견 번호는 6라운드에서 이어서 `H-55`부터, 패스를 가로질러 연속으로 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "문제가 없던 것" 부록은 다시 파지 않아도 되는 자리를 적어둔 것. **아직 아무것도 `base/`에 반영하지 않았다** — 판정은 사용자가 하고, 결정이 나면 6라운드처럼 `-followup.md`를 새로 만든다). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-19 기준] 폴더 자체가 아직 없음**(M0/M1 스캐폴딩만으론 안 생기고 실제로 렌더링해보고 쓰는 단계부터, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | diff --git a/.claude/qa-request/pre-implementation-handtrace-round7.md b/.claude/qa-request/pre-implementation-handtrace-round7.md index d3bb263..a938bcf 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round7.md +++ b/.claude/qa-request/pre-implementation-handtrace-round7.md @@ -1,12 +1,13 @@ # 구현 전 손 트레이싱 **7라운드** — M2(반응형 코어) 범위 + M2→M3 경계 -**구성**: **패스 3개, 발견 30건(`H-55`~`H-84`).** 1차 패스는 문서 대 문서 +**구성**: **패스 4개, 발견 39건(`H-55`~`H-93`).** 1차 패스는 문서 대 문서 손 트레이싱(`H-55`~`H-70`), **2차 패스는 문서가 "확인했다"고 적은 주장을 실제로 `luau`/`luau-analyze`에 걸어본 실측**(`H-71`~`H-76`, 2026-08-25 추가), **3차 패스는 (a) 실제로 커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려본 것과 (b) 1·2차가 범위에서 뺐던 "M2를 *소비하는* 문서"**(`H-77`~`H-84`, -2026-08-25 추가). 발견 번호는 패스를 가로질러 이어서 매긴다(6라운드와 같은 -방식). +2026-08-25 추가), **4차 패스는 (a) M2 코어를 문서 그대로 Luau로 짜서 돌린 +참조 구현과 (b) 아무 문서도 안 정한 예외 경로**(`H-85`~`H-93`, 2026-08-25 +추가). 발견 번호는 패스를 가로질러 이어서 매긴다(6라운드와 같은 방식). **상태**: **[2026-08-25] 발견 보고 — 아무것도 반영하지 않았다.** 판정은 사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을 @@ -98,6 +99,23 @@ GC 실측, `store:GetDynamic` 위치)에서 파생되는 것. 아래에서 그 | `H-83` | 🟢 | 확정된 Store 구현 스케치를 그대로 쓰면 **무인자 `Store()`가 `table.clone(nil)`로 크래시**한다 — 같은 문서가 `defaults`는 선택이라고 확정해뒀다 | `store-plan.md` | ✅ `table expected, got nil` | | `H-84` | 🟢 | `:With(...)`/`state:Block(b)`/`Source:Emit()`이 M2 체크리스트에 개별 항목으로 없다 — `:Compute`/`:Apply`/`:Observer`는 각각 있는데 | `ROADMAP.md` M2 | 표면 대조 | +**⭐ [2026-08-25] 4차 패스 — 문서대로 짠 참조 구현 실측 + 예외 경로 (`H-85`~`H-93`).** +상세는 아래 "4차 패스" 절. 아래 표의 마지막 열 ✅는 **문서를 방어 코드 없이 +그대로 옮긴 참조 구현으로 재현했다**는 뜻이다(그 구현이 문서를 제대로 옮겼다는 +대조군은 4차 패스 부록의 다이아몬드 항목). + +| 번호 | 심각도 | 한 줄 | 주 대상 | 실측 | +|---|---|---|---|---| +| `H-85` | 🔴 | **재계산이 끝날 때 세우는 `rawInvalid = false`가 재계산 *도중* 도착한 무효화를 지운다** — 캐시가 다음 `Set`까지 영구 stale | `state-epoch-plan.md` §4 | ✅ 재현·한 줄 수정까지 대조 | +| `H-86` | 🔴 | 정책은 "지금 보류된 게 있는가"를 **읽을 수 없다** — `Throttle`의 창이 idle로 못 돌아와 leading이 영구 소실되고, 타이머 체인이 안 끝나 §8의 "유계 GC"도 깨진다 | `gate-plan.md` 5번, `debounce-throttle-plan.md` 1-1·8절 | ✅ 두 변형 대조 | +| `H-87` | 🔴 | 배치 게이팅 도중 error → 그 owner의 `Blocker`가 **영구 On**, 그 자리 `recompute`가 조용히 영영 안 돈다 | `dispatch-core-plan.md` 배치 게이팅 절 | ✅ 재현 | +| `H-88` | 🟡 | 전파 도중 콜백이 던지면 **그 파동의 나머지 구독자는 영구 침묵**(값만 자가치유) — 예외 안전성을 정한 문장이 코퍼스에 0건 | 전파 루프 전반 | ✅ 재현 | +| `H-89` | 🟡 | flush 중 error → 떼어낸 배치 소멸, 그리고 `:Sync(batch)`와 전파의 **순서가 문서에 없어** 복구 가능성이 갈린다 | `gate-plan.md` 4번 | ✅ 재현 | +| `H-90` | 🟡 | `Effect`의 dedup이 **루트 에포크** 기준이라, dep 하나만 게이팅하면 다른 dep이 같은 루트를 공유할 때 **게이팅이 무력화** | `effect-plan.md`, `gate-plan.md` 3번 | ✅ 재현 | +| `H-91` | 🟢 | §8의 *"항상 state 는 get 이 최신"* 이 과한 서술 — `Animate`가 그 반례를 **설계로** 쓴다 | `state-epoch-plan.md` §8, `tween-plan.md` | 문서 정합 | +| `H-92` | 🟢 | 확정된 구독자 스냅샷이 **emit마다·노드마다 배열 하나**를 할당 — §2가 테이블 리비전을 기각한 GC 근거와 어긋난다 | `ROADMAP.md` M2, `state-epoch-plan.md` §2 | 구현 시 정하면 | +| `H-93` | 🟢 | `EpochMap` 키가 weak라, 중간 State GC 미해결이 **"낡았는데 최신이라고 오판"**으로도 나타난다 | `state-epoch-plan.md` §3 | 미해결의 파생 | + --- # 1차 패스 — 문서 대 문서 손 트레이싱 @@ -1515,6 +1533,595 @@ false invalid argument #1 to 'clone' (table expected, got nil) --- +# 4차 패스 — 문서대로 짠 참조 구현을 실제로 돌렸다 + 예외 경로 (2026-08-25) + +**왜 이 패스가 있는가**: 사용자 요청 — *"저기에 포함되지 않은 문제점을 +더 찾아봐. 찾은 다음에 진짜 있는 문제인지 재검증까지 다 해줘. 찾은걸 +이어붙이면 돼"*. + +**1~3차와 다른 각도 둘**: + +1. **M2 코어를 문서 그대로 Luau로 짜서 돌렸다.** 2차는 *Luau 언어의* + 동작(GC/타입)을 최소 재현으로 걸었고, 3차는 *저장소에 이미 있는 것*을 + 돌렸다. 이번엔 `state-epoch-plan.md` §2~§5 / `source-state-plan.md`의 + 전파 모델 / `gate-plan.md` 4·8번 / `blocker-plan.md`를 **방어 코드 없이 + 문장 그대로** 옮긴 참조 구현(약 150줄)을 만들고 시나리오를 돌렸다. 손 + 트레이싱이 못 잡는 것 — 여러 규칙이 **동시에** 걸리는 자리 — 이 여기서 + 나온다. 참조 구현이 문서를 제대로 옮겼다는 검증은 아래 부록의 다이아몬드 + 대조군(§1이 예고한 glitch가 실제로 사라지는가)이 한다. +2. **예외 경로.** `base/`의 반응형 문서 셋(`state-epoch-plan.md` / + `gate-plan.md` / `blocker-plan.md`)엔 "error"라는 단어가 **한 번도** + 안 나온다. 사용자 콜백(`:Compute`의 `fn`, Observer, `Effect`)이 던졌을 때 + 무엇이 남는지를 정한 문장이 코퍼스에 없다 — 그런데 그 자리는 전부 + **부기를 반쯤 갱신한 상태**다. + +**실측 환경**: `luau` (`~/.local/share/mise/installs/luau/latest`), +2026-08-25 실행. 참조 구현과 시나리오는 세션 스크래치패드에서 돌렸고 +저장소 파일은 건드리지 않았다(각 항목에 재현 코드가 그대로 들어 있다). + +**이 패스의 범위**: `base/state-epoch-plan.md` §3~§5·§7·§8 / +`base/gate-plan.md` 4·5·6·8번 / `base/blocker-plan.md` / +`base/debounce-throttle-plan.md` **전문**(1~3차는 7절 배너만 봤다) / +`base/effect-plan.md`의 `EpochMap` dedup / `base/dispatch-core-plan.md`의 +"배치 등록을 안전하게 만드는 Blocker 게이팅" / `base/tween-plan.md`의 +`Animate` / `base/brand-plan.md` / `base/architecture.md` 소스 트리 / +`base/fallback-plan.md` / `base/event-plan.md` / `base/onchange-plan.md` / +`base/purity-and-effects-plan.md` / `ROADMAP.md` M2. +**`H-55`~`H-84`와 겹치는 항목은 없다** — 겹칠 뻔한 자리는 항목 안에 +"이건 `H-xx`와 별개다"로 적었다. + +| 번호 | 심각도 | 한 줄 | 주 대상 | 실측 | +|---|---|---|---|---| +| `H-85` | 🔴 | **재계산이 끝날 때 세우는 `rawInvalid = false`가 재계산 *도중* 도착한 무효화를 지운다** — 그 노드의 캐시가 다음 `Set`까지 영구히 stale로 굳는다 | `state-epoch-plan.md` §4 | ✅ 재현·한 줄 수정까지 대조 | +| `H-86` | 🔴 | 정책은 "지금 보류된 게 있는가"를 **읽을 수 없다** — `Throttle`/`Debounce`의 창 상태 기계가 성립 안 하고, leading이 첫 버스트 뒤 영구 소실되며 타이머 체인이 안 끝나 §8의 "유계 GC" 주장도 깨진다 | `gate-plan.md` 5번, `debounce-throttle-plan.md` 1-1·8절 | ✅ 두 변형 대조 | +| `H-87` | 🔴 | 배치 게이팅 도중 error가 나면 그 owner의 `Blocker`가 **영구 On**으로 남아 그 자리 `recompute`가 조용히 영영 안 돈다 — 문서는 yield만 UB로 못박고 error는 안 다룬다 | `dispatch-core-plan.md` 배치 게이팅 절, `blocker-plan.md` | ✅ 재현 | +| `H-88` | 🟡 | 전파 도중 사용자 콜백이 던지면 **그 파동의 나머지 구독자는 그 변경에 대해 영구 침묵**한다(값은 자가치유되지만 push 소비자는 아님). 예외 안전성을 정한 문장이 코퍼스에 없다 | 전파 루프 전반 | ✅ 재현 | +| `H-89` | 🟡 | 게이트 flush 중 error → **떼어낸 배치가 통째로 소멸**하고, `:Sync(batch)`가 전파보다 앞이면 같은 리비전의 재도착까지 규칙 3으로 삼켜진다. 그 **순서가 문서에 없다** | `gate-plan.md` 4번 | ✅ 재현 | +| `H-90` | 🟡 | `Effect`의 공용 `EpochMap`은 **루트 에포크**로 접으므로, dep 하나에만 게이트를 걸어도 다른 dep이 같은 루트를 공유하면 **게이팅이 통째로 무력화**된다 | `effect-plan.md`, `gate-plan.md` 3번 | ✅ 재현 | +| `H-91` | 🟢 | `state-epoch-plan.md` §8의 *"이제 정말로 항상 state 는 get 이 최신을 던지는게 맞다"* 가 과한 서술 — `Animate`가 정확히 그 반례를 **설계로** 쓴다 | `state-epoch-plan.md` §8, `tween-plan.md` | 문서 정합 | +| `H-92` | 🟢 | 확정된 구독자 스냅샷(`H-23`)이 **emit마다·노드마다 배열 하나**를 할당한다 — §2가 테이블 리비전을 기각한 GC 근거와 정면으로 어긋난다(`H-69`의 게이트 판이 전 노드로 확대된 것) | `ROADMAP.md` M2, `state-epoch-plan.md` §2 | 구현 시 정하면 | +| `H-93` | 🟢 | `EpochMap` 키가 weak라, 중간 State GC 미해결은 "전파가 끊긴다"만이 아니라 **"낡았는데 최신이라고 오판한다"**로도 나타난다 | `state-epoch-plan.md` §3, `source-state-plan.md` 미해결 절 | 미해결의 파생 | + +--- + +## 🔴 `H-85` — 재계산 도중 도착한 무효화를 재계산의 마지막 줄이 지운다 (실측) + +**어디**: `base/state-epoch-plan.md`의 "재계산이 끝나면" 절 — +*"**`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류 +전부에 대해 갱신한다**"*. 그리고 같은 문서 "재계산 판정" 절. + +**무엇이 어긋나나**: 두 문장이 시간축에서 겹친다. + +1. `Get()` → `rawInvalid`가 참 → `fn`을 부른다. `fn`은 **사용자 코드**다. +2. `fn`이 도는 동안 어떤 경로로든 상류 `Source`가 `:Set()`되면, 그 emit이 + **지금 재계산 중인 노드에 정상적으로 도착**해 §4의 규칙 1로 + `rawInvalid = true`를 세운다. 여기까지는 문서대로 옳다. +3. 그런데 `fn`이 반환한 뒤 재계산의 마지막 줄이 **무조건** + `rawInvalid = false`를 쓰고, `valueEpochMap`을 **그 시점의 라이브 + 리비전**으로 채운다. 방금 세워진 무효화가 지워지고, 동시에 맵은 "나는 + 그 새 리비전에 대해 최신"이라고 거짓 기록을 남긴다. + +→ 그 노드는 **`fn`이 읽지 않은 값으로 계산된 캐시**를 들고 "최신"으로 +표시된다. `rawInvalid`가 거짓이라 순회(`:Refresh()`)를 도는데 맵도 이미 +라이브 값이라 차이가 없다 → **다음 `Set`이 올 때까지 영구히 옛값을 준다.** + +**재현**(참조 구현, `fn` 안에서 상류를 한 번 바꾼다): + +```lua +local A = q.Source(1) +local D = q.State(function(self, prev, a) + local v = a:Get() + if v == 1 then A:Set(99) end -- 재계산 도중 상류가 바뀜 + return v * 2 +end, {A}, "D") + +print(D:Get()) -- 2 (A=1로 계산 — 여기까진 정상) +print(A:Get()) -- 99 +print(D:Get()) -- 2 ← 198이어야 하는데 영구히 2 +``` + +``` +D:Get() = 2 (A=1로 계산됨) +A의 실제 값: 99 +다시 D:Get() = 2 ← 기대값 198 +D 재계산 횟수: 1 +``` + +`A:Set(5)`처럼 **또 한 번** 바꾸면 그때 회복된다 — 즉 조용히 한 세대를 +건너뛰는 형태라 관측이 어렵다. + +**트리거가 얼마나 현실적인가**: `fn`이 순수해야 한다는 강제는 코퍼스에 +없다 — `base/purity-and-effects-plan.md`가 *"린트 규칙이나 런타임 경고 +같은 기술적 강제는 하지 않음(확정)"*으로 못박았고, 그 문서가 다루는 것도 +"순수성"이 아니라 이식성이다. 실제로 밟기 쉬운 모양 셋: + +- `fn` 안에서 계산 결과를 다른 Store 필드에 적어두는 관용구 + (`store.lastComputed:Set(...)`). +- `fn`이 부른 헬퍼가 lazy 초기화를 하며 Source를 채우는 경우. +- **`fn`이 yield하는 경우** — `dispatch-core-plan.md`가 못박은 yield 금지 + 불변식은 `Dispatch.process`/`attachSlot` 체인 안에 한정이고, 그 밖에서 + 부르는 `:Get()`엔 적용되지 않는다. yield 사이에 타이머(`Debounce`의 + `setTimeout` 콜백 등)가 `Set`하면 같은 자리가 된다. + +**이건 `H-56`과 별개다** — 그쪽은 전파 루프가 자식 State에 **닿지 +않는다**는 문제이고, 이쪽은 정상적으로 닿아 세운 플래그가 **지워진다**는 +문제다. `H-68`(같은 값 `Set`)과도 무관하다. + +**갈래(결정 전 목록)**: (a) **`rawInvalid = false`를 `fn` 호출 *앞*으로 +옮긴다** — 도중에 다시 참이 되면 그대로 둔다. 한 줄이고, 실제로 이걸로 +고쳐지는 걸 대조군으로 확인했다: + +```lua +self.rawInvalid = false -- ← 먼저 내린다 +self.cache = self.fn(self, self.cache, ...) +for _, d in self.deps do d:_track(self.valueEpochMap) end +-- fn 도중 다시 true가 됐으면 덮어쓰지 않는다 +``` + +``` +D:Get() = 2 +다시 D:Get() = 198 ← 고쳐짐 +재계산 횟수: 2 +``` + +(b) `fn` 진입 **전에** 상류 리비전을 스냅샷해두고 끝에서 그 스냅샷을 +쓴다 — 맵까지 정직해지지만 (a)보다 비싸고, (a)만으로도 `rawInvalid`가 +남아 다음 `Get`이 반드시 재계산하므로 맵의 거짓 기록은 무해해진다. +(c) "재계산 중 상류 `Set`은 UB"를 yield 금지와 같은 톤으로 명문화한다 — +비용 0이지만, 위 세 관용구가 전부 UB가 된다. + +--- + +## 🔴 `H-86` — 정책은 "보류분이 있는가"를 읽을 수 없다 → `Throttle`의 창이 idle로 못 돌아온다 (실측) + +**어디**: `base/gate-plan.md` 5번(*"**`pending` 같은 정책 상태는 +`HasBlockedEmit`으로 흡수한다** — '보류된 게 있는가'를 Blocker가 이미 +들고 있으므로 중복 상태를 안 만든다"*), `base/debounce-throttle-plan.md` +7절 배너(*"**`pending`은 없앤다**"*)와 "1-1. 정확히 어떤 동작인가" 절, +그리고 같은 문서 "8. 라이프사이클 / GC 분석" 절. + +**무엇이 어긋나나**: 확정된 재작성 방향은 정책에서 `pending`을 **없애라**고 +하는데, 정책이 그 자리를 대신할 값을 읽을 통로가 **하나도 없다**. + +- `setup: (emit) -> onUpstreamEmit`이 주는 건 flush 핸들 하나뿐이고 + `emit()`은 반환값이 없다. +- `HasBlockedEmit`은 `base/blocker-plan.md`가 **gated state**의 필드로 + 확정했고, `gate-plan.md` 4번이 그걸 노드의 `withheld`로 흡수하면서 + *"구현 시 두 개를 따로 들지 말 것"*까지 못박았다. 즉 `Blocker` 객체 + 쪽엔 그 값이 **실체로 없다**. +- `blocker:IsOn()`은 "지금 막고 있는가"이지 "쌓인 게 있는가"가 아니다. + +그런데 `debounce-throttle-plan.md`가 확정한 동작표는 그 값을 **두 자리** +에서 요구한다. `onWindowEnd`의 *"`pending`이 없으면 창을 안 열고 완전히 +idle로 복귀"*, 그리고 `MaxTime` 재무장 조건(`if opts.MaxTime and cap == nil +and pending`). + +**손 트레이스 겸 실측** — `Throttle{Time = 1}`을 확정된 재작성 방향 그대로 +(정책이 `emit`을 안 쥐고 자기 `Blocker`만 조종) 두 변형으로 짜서 돌렸다. +`nopending`은 문서가 확정한 대로 정책에서 `pending`을 없앤 것, +`localpending`은 정책이 자기 플래그를 따로 든 것이다. 입력은 +`debounce-throttle-plan.md` 1-1절이 그림으로 못박은 그 시나리오 +(t=0.0, t=0.1 입력 → 조용 → t=3.0 새 입력): + +``` +=== variant = localpending === + 타이머 살아있는 개수(idle이면 0): 0 + 통과: t=0.00 v=1 | t=1.00 v=2 | t=3.00 v=3 ← 1-1절 그림과 일치 + t=5까지 살아있는 타이머: 0 + +=== variant = nopending === + 타이머 살아있는 개수(idle이면 0): 1 + 통과: t=0.00 v=1 | t=1.00 v=2 | t=4.00 v=3 ← t=3.0의 leading이 사라짐 + t=5까지 살아있는 타이머: 1 +``` + +두 가지가 동시에 깨진다. + +1. **leading이 첫 버스트 이후 영구 소실된다.** leading은 `window == nil` + (창 밖)일 때만 발화하는데, 창을 닫을 조건을 못 읽으니 창이 **영원히 + 열린 채**다. t=3.0의 입력이 즉시 통과하지 못하고 t=4.0까지 밀린다 — + *"첫 신호는 즉시 통과"*라는 스로틀의 정의가 사라지고 사실상 "항상 + trailing"이 된다. +2. **타이머 체인이 끝나지 않는다.** 그래서 + `debounce-throttle-plan.md`의 "8. 라이프사이클 / GC 분석" 절이 확정한 + *"유계이고 자가 치유됨 — 누수가 아니라 '지연된 GC'"*, *"최대 `Time` + (또는 `MaxTime`)초 동안은 노드와 그 상류 체인 … 이 살아 있음"*이 + **성립하지 않는다.** 타이머가 자기를 무한히 재무장하므로 그 게이트 + 노드와 상류 체인은 **영구히** 살아 있다. 같은 절이 위험 조합으로 지목한 + *"긴 `Time` + 빠른 생성/파괴"*(`:List` 항목마다 게이트)가 그대로 누수가 + 된다. + +**이건 `H-55`와 별개다.** `H-55`는 정책이 흡수 집합을 **버릴** 수 없다는 +쓰기 방향이고, 이쪽은 **읽을** 수 없다는 방향이다. `H-55`를 (a)/(b)/(c) +어느 갈래로 닫아도 이건 안 닫힌다 — 버리기 핸들이 하나 더 생겨도 +"지금 쌓인 게 있는가"는 여전히 안 보인다. 다만 뿌리가 같으므로(정책이 +노드 상태에 접근할 통로가 없다) **같이 결정하는 게 낫다.** + +**갈래(결정 전 목록)**: (a) **`emit()`이 boolean을 반환한다** — "실제로 +내보냈는가". 인자는 그대로라 `H-49`의 "시그니처는 안 바뀐다"를 최소로만 +되짚고, `H-55`의 (b)안(`emit(false)`가 버리기)과도 그대로 합성된다 +(`emit(false)` 역시 "버릴 게 있었는가"를 반환). 위 `nopending` 변형은 이걸 +쓰면 `localpending`과 동일해진다. (b) `setup(emit, hasWithheld)`처럼 조회 +핸들을 하나 더 준다. (c) **확정 문장을 철회하고 정책이 자기 `pending`을 +다시 든다** — 그러면 `H-32`(`Trailing = false`에서 `pending`이 영구 참으로 +남던 결함)를 다시 손으로 막아야 하고, `gate-plan.md` 5번의 *"중복 상태를 +안 만든다"* 와 7절 배너의 *"이게 `H-32`를 구조적으로 없앤다"* 를 같이 +고쳐야 한다. + +--- + +## 🔴 `H-87` — 배치 게이팅 도중 error가 나면 그 owner의 Blocker가 영구 On으로 남는다 (실측) + +**어디**: `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 +Blocker 게이팅" 절(1~4번), `base/blocker-plan.md`의 "재진입(네스팅)" 절. + +**무엇이 어긋나나**: 그 절이 확정한 배치는 **`On()` … 작업 … `OffWithoutEmit()`** +모양이고, 중간의 "작업"엔 **사용자 코드가 들어간다**(컴포넌트가 만든 +props의 핸들러, `updateFn`, `PostRef` 콜백 — 같은 절이 *"그 콜백은 사용자 +코드이고"*라고 직접 인정한다). 그 구간에서 error가 던져지면 4번의 +`OffWithoutEmit()`이 **실행되지 않는다.** + +그 Blocker는 임시 변수가 아니라 **`Relate(ownerKey)`에 lazy 생성돼 계속 +재사용**되므로(같은 절 1번, `getBlocker(ownerKey)`), 한 번 켜진 채로 남으면 +그 owner에 대한 이후 **모든** `gatedRecompute`가 조용히 스킵된다. + +**재현**(같은 절의 1~4번을 그대로 옮긴 것): + +```lua +local function drive(owner, positions) + local bl = getBlocker(owner) + bl:On() + for _, p in positions do + p() -- 각 position 처리(여기서 사용자 코드가 돈다) + gatedRecompute(owner) -- blocker:IsOn()이면 스킵 + end + bl:OffWithoutEmit() -- 4번 + recomputes += 1 -- "그 직후 딱 한 번" +end +``` + +``` +-- 두 번째 배치에서 사용자 코드가 error -- +drive가 던졌는가: true | blocker.IsOn(): true ← 영구 On +-- 이후 런타임 :Add() 등이 부르는 setLength -- +recompute가 몇 번 돌았나: 0 (그 owner는 영영 재계산 안 됨) +``` + +**왜 조용한가**: Roblox는 이벤트 콜백 안에서 던져진 error를 **엔진이 잡아 +로그만 찍고 실행을 계속**한다. 즉 사용자가 보는 건 출력창의 빨간 줄 하나고, +그 뒤로 그 인스턴스의 Length/Offset 부기만 영원히 멈춘 채 나머지는 정상 +동작한다 — 레이아웃이 조금씩 어긋나는데 원인 추적이 극히 어려운 모양이다. +`base/fallback-plan.md`의 `Fallback`/`Traceback`은 이 자리를 안 덮는다 — +그건 컴포넌트 **함수 호출**을 감싸는 슈가이고, 그 호출은 props 테이블을 +만드는 시점(=`drive` 진입 전)에 끝난다. + +**자가치유가 되는 경우와 안 되는 경우**: 같은 owner로 **또 배치가 열려 +정상 종료되면** 그때 꺼진다(위 실측 마지막 줄). 그래서 +`materializeSlotTree` 쪽(같은 Slot에 다시 마운트가 일어날 수 있음)은 +언젠가 회복될 수 있지만, `Dispatch.drive`의 owner는 `inst`이고 그 +인스턴스의 배열 파트 배치는 **생성 시 한 번**뿐이라 사실상 영구다. + +**문서가 이미 옆자리는 막아뒀다**: 같은 절의 마지막 문단이 **yield**를 +UB로 못박았고(*"모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 +할듯"*), `base/blocker-plan.md`의 "재진입(네스팅)" 절은 레퍼런스 카운팅을 +기각하며 그 근거로 *"'`On()` 여러 번, `Off()` 실수로 적게' 같은 버그가 +**영구 블록으로 조용히 새는** 더 위험한 실패 모드"*를 든다. **error 경로는 +그 실패 모드에 정확히 해당하는데 어느 쪽도 다루지 않는다.** + +**갈래(결정 전 목록)**: (a) 배치를 여는 자리만 `pcall`/`xpcall`로 감싸 +`OffWithoutEmit()`을 보장하고 error는 다시 던진다(감싸는 자리는 정확히 +둘 — `Dispatch.drive`와 `materializeSlotTree`, 같은 절이 "적용 지점"으로 +명시한 그 둘). hot path가 아니라 배치당 1회라 비용도 배치당 하나다. +(b) yield 금지와 같은 톤으로 **"배치 구간에서 던지면 UB"**를 명문화한다 — +비용 0이지만 Roblox가 error를 삼키는 이상 사용자가 UB에 들어간 줄도 모른다. +(c) `gatedRecompute`가 "이 배치가 아직 살아 있는가"를 확인할 수 있게 +배치에 세대 번호를 붙인다 — 구조가 늘어나므로 +`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 +구조를 복잡하게 만들지 않는다" 원칙에 비추면 (a)가 낫다. + +--- + +## 🟡 `H-88` — 전파 도중 콜백이 던지면 나머지 구독자는 그 변경에 대해 영구 침묵한다 (실측) + +**어디**: `ROADMAP.md` M2의 "State 전파 루프" 체크박스와 `H-23` 스냅샷 +항목, `base/state-epoch-plan.md` §4 전체. + +**무엇이 어긋나나**: 전파는 `Source:Set()` 한 번의 **콜스택 하나**로 +끝까지 도는 동기 DFS다. 그 안에서 사용자 콜백(Observer/Effect의 `fn`, +그리고 그 `fn`이 부르는 `:Get()` → `:Compute`의 `fn`)이 던지면 스택이 +통째로 풀리면서 **아직 방문하지 않은 구독자는 그 파동에서 영영 빠진다.** + +에포크 모델의 자가치유는 이걸 **절반만** 덮는다: + +- **값은 낫는다** — 안 방문된 노드는 `valueEpochMap`이 안 갱신됐으므로 + 다음 `:Get()`의 순회(`:Refresh()`)에서 스스로 낡음을 알아채고 재계산한다. +- **통지는 안 낫는다** — `:Get()`을 안 부르는 소비자(Observer, `Effect`, + 그리고 그 위에 얹힌 store-bind 재디스패치)는 **자기를 깨워줄 사람이 + 없다.** 다음 진짜 emit이 올 때까지 침묵하고, 그 사이의 변경은 통째로 + 건너뛴다. + +**재현**: + +```lua +local A = q.Source(1) +local B = q.State(function(self, prev, a) return a:Get() end, {A}, "B") +local C = q.State(function(self, prev, a) return a:Get() end, {A}, "C") +local bad = q.Observer(B, function() error("사용자 콜백 실패") end) +local good = q.Observer(C, function() table.insert(seen, C:Get()) end) +``` + +``` +-- A:Set(2) (전파 중 O_bad가 error) -- +Set이 던졌는가: true | O_bad 발화: 1 | O_good 발화: 0 +C의 값은 자가치유되는가: C:Get() = 2 (정상) +-- 이후 A:Set(3) -- +두 번째 Set도 같은 자리에서 던짐 | O_good 발화: 0 +-- O_bad를 떼고 A:Set(4) -- +O_good 발화: 1 | 관측: 4 ← 2와 3에 대한 통지는 영영 안 옴 +``` + +부수로 확인된 것 둘: + +- **`Source:Set()`이 사용자 error를 그대로 밖으로 던진다.** 즉 + `store.key:Set(v)` 한 줄이, 그 값과 아무 관계도 없는 먼 하류 Observer의 + 버그 때문에 실패할 수 있다 — 그리고 그 `Set`을 부른 쪽(이벤트 핸들러 등)의 + 나머지 코드도 같이 죽는다. `H-87`이 그 구체적 피해 사례다. +- **순서가 결과를 가른다.** 어느 구독자가 살아남는지는 `H-23`이 확정한 + 스냅샷 배열의 순서, 즉 **해시 순회 순서**에 달렸다 — 실행마다 다를 수 + 있다(`H-23`이 원래 스냅샷을 도입한 이유와 같은 종류의 비결정성이 + error 경로에서 되살아난다). + +**필요한 것**: 코퍼스에 **예외 안전성 계약이 한 줄도 없다** — +`state-epoch-plan.md`/`gate-plan.md`/`blocker-plan.md` 셋에 "error"라는 +단어가 0건이다. 갈래: (a) 전파 루프가 구독자마다 `pcall`로 감싸고 실패한 +구독자만 건너뛴다(나머지 파동은 정상 완주, error는 로그) — Roblox의 이벤트 +디스패치가 하는 것과 같은 모양이고, `Fallback`이 이미 세운 +"에러 격리는 있는 게 낫다"는 방향과도 맞는다. 대가는 구독자당 `pcall` +하나(전파는 hot path다). (b) "콜백이 던지면 그 파동은 UB"를 명문화한다. +(c) 파동 단위로 하나만 감싸고(진입점 `Set`/flush) 실패 시 남은 구독자를 +버리되 **그 사실을 알린다**. **어느 쪽이든 지금은 아무 문장도 없어서 +구현자가 임의로 정하게 된다.** + +--- + +## 🟡 `H-89` — flush 중 error는 떼어낸 배치를 통째로 없애고, `:Sync`의 순서가 그 복구 가능성을 가른다 (실측) + +**어디**: `base/gate-plan.md` 4번의 *"전파 페이로드는 `withheld` 자체가 +아니라 flush 진입 시점에 떼어낸 스냅샷이다"* 항목과, 같은 절의 *"게이트의 +`emitEpochMap`은 수신 때가 아니라 실제로 전파할 때 갱신한다"* 항목. + +**무엇이 어긋나나**: flush는 확정된 대로 **진입하는 순간** 집합을 새 +테이블로 스왑한다(`H-9` 반영). 그건 재진입 안전을 위해 옳지만, **전파가 +error로 풀리면 그 배치를 다시 붙잡을 곳이 아무 데도 없다** — 게이트의 +`withheld`는 이미 빈 새 테이블이고, 떼어낸 `batch`는 스택과 함께 사라진다. + +여기에 두 번째 문제가 겹친다. 확정문은 `emitEpochMap:Sync(batch)`가 +"실제로 전파할 때" 돈다고만 하고 **전파 앞인지 뒤인지를 안 정한다.** +그런데 그 순서가 error 이후의 복구 가능성을 정반대로 가른다: + +- **`Sync`가 앞이면**: 게이트는 "그 리비전은 이미 내보냈다"고 기록한 채 + 실제로는 못 내보냈다. 같은 리비전이 **다른 경로로 다시 도착해도** §4의 + 규칙 3(둘 다 같음)으로 삼켜져 정책조차 안 돈다 → **영구 침묵.** +- **`Sync`가 뒤면**: error로 `Sync`가 안 돌았으므로 재도착이 규칙 2(통지만)로 + 걸려 정책이 다시 돌고 복구 여지가 생긴다. + +**재현**(`Sync`를 전파 앞에 둔 형태): + +``` +보류됨. withheld 비었나: false +flush 진입 후 withheld 비었나(스왑됨): true | flush 횟수: 1 +같은 리비전 재도착 후 o 발화: 1 | 관측: ← 삼켜져서 아무것도 안 나감 +(값은 살아있다: g:Get() = 2) +``` + +**같은 뿌리의 두 번째 자리 — `blocker:Off()`의 핸들 순회.** +`base/blocker-plan.md`는 `Off()`를 *"등록된 onunblock 핸들 전부 실행"*으로 +확정했는데, 앞선 핸들의 flush가 던지면 **뒤쪽 gated state는 안 풀린다** — +그런데 `IsBlocked`는 이미 `false`다(같은 문서가 *"`IsBlocked = false`로 +먼저 설정"*으로 못박음). 즉 **"안 막혀 있는데 밀린 통지를 든 채 멈춘"** +상태가 남는다: + +``` +Off()가 던졌는가: true | blocker.IsBlocked: false +g2가 아직 붙들고 있나(비었으면 flush됨): false | o2 발화: 0 +``` + +`blocker:Off()`를 **한 번 더** 부르면 g2가 정상적으로 풀리는 걸 +확인했지만(첫 핸들은 이미 배치를 비웠으므로 8번의 "빈 배치는 아무것도 +안 함"으로 조용히 지나간다), 호출자가 그걸 알 방법이 없다. + +**갈래**: (a) `:Sync(batch)`를 **전파 뒤**로 확정한다(문서에 순서를 명시). +그 자체로는 재진입 시 "안 던진 리비전을 던졌다고 기록"하는 반대편 흠이 +생기는데, 실측해보니 그 기록은 이미 `withheld`에 들어 있는 원천에 대한 +것이라 관측 가능한 손실을 안 만든다 — 반면 error 쪽 손실은 관측된다. +(b) flush 전체를 `pcall`로 감싸고 실패 시 배치를 `withheld`에 되돌린다 +(`H-88`의 (a)와 같은 결). (c) `Off()`의 핸들 순회를 실패해도 계속 돌게 한다. +**어느 쪽이든 `H-88`과 한 번에 결정하는 게 낫다** — 전부 "예외가 나면 +부기를 어디까지 되돌리는가" 하나의 질문이다. + +--- + +## 🟡 `H-90` — `Effect`의 dedup이 루트 에포크 기준이라, dep 하나만 게이팅하면 게이팅이 무력화된다 (실측) + +**어디**: `base/effect-plan.md`의 *"의존성들이 공통 상류를 공유해도 한 +파동에 `fn`은 한 번만 돈다"* 항목(확정 의사코드 포함), +`base/gate-plan.md` 3번(*"게이트를 통과하지 않은 값도 `:Get()`으로는 +보인다"*). + +**무엇이 어긋나나**: 두 확정이 각자 옳은데 겹치면 게이트가 사라진다. + +1. `Effect`의 공용 `EpochMap`은 **`from`(루트 `Epoch`나 그 집합)** 으로 + 접는다 — "어느 dep이 깨웠는가"는 안 본다. +2. 게이트는 **통지만** 막고 값은 안 막는다. 그래서 게이트 뒤 dep을 + `:Get()`하면 유보 중에도 최신값이 나온다. + +→ `Effect(fn, gatedDep, plainDep)`에서 둘이 **같은 루트**를 공유하면, +`plainDep` 쪽 통지가 먼저 도착해 `fn`이 **창 안에서 그대로 실행**되고 +(그때 `gatedDep:Get()`은 최신값을 준다), 나중에 게이트가 실제로 flush할 때 +오는 통지는 **이미 그 리비전을 봤다**며 규칙대로 접힌다. + +**재현**: + +```lua +-- A ──> gated(Block) ──┐ +-- └──> plain ─────────┴──> Effect(fn, gated, plain) +b:On() +A:Set(2) +``` + +``` +blocker ON — 이 구간의 변경은 gated 쪽에서 유보되어야 한다 +fn 실행 기록: run(gated=2, plain=2) ← 막혀 있는데 이미 돌았다 +gated가 아직 붙들고 있나: true +Off() 이후 fn 실행 기록: run(gated=2, plain=2) ← flush는 접힘 +gated observer 발화 횟수: 1 | plain observer: 1 +``` + +**왜 문제로 보는가**: 사용자가 `state:Apply(Debounce{...})`로 만든 dep을 +`Effect`에 넣는 건 *"이 이펙트를 디바운스한다"*는 뜻인데, 같은 Store의 +다른 필드에서 파생된 dep이 하나만 더 끼어도 그 의도가 **조용히** 무효가 +된다. 게이트가 하나뿐인 dep일 땐 정상 동작하므로, 나중에 dep을 하나 더 +추가하다 회귀가 나는 모양이다. + +**미확정 — 계약 결정이 필요하다**: "값 관점에선 `fn`이 항상 최신을 봤으니 +맞다"고 볼 수도 있다(그 경우 이건 결함이 아니라 **문서화 대상**이다 — +`gate-plan.md` 3번의 공개 계약이 `Effect`에서 어떻게 보이는지). 반대로 +"게이트를 dep에 걸었으면 발화 시점이 미뤄져야 한다"고 보면 dedup 키를 +루트가 아니라 **(dep, 루트)** 쌍으로 바꾸거나, 게이트 뒤 dep은 dedup에서 +빼야 한다. 어느 쪽이든 지금은 어디에도 안 적혀 있다. + +**이건 `H-64`와 별개다** — 그쪽은 포탈 언마운트 구간에서 dep **종류**에 +따라 캐치업이 갈리는 문제이고, 이쪽은 정상 구간에서 **게이트가 무력화** +되는 문제다. + +--- + +## 🟢 `H-91` — "항상 `Get`이 최신"이라는 §8의 문장이 과하다 (`Animate`가 반례를 설계로 쓴다) + +**어디**: `base/state-epoch-plan.md`의 "8. 구현 시 확인할 것" 절, +"선언 안 된 의존성에 대한 UB 조항은 안 만든다" 항목의 인용 — +*"이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을 던지는게 맞다. +상류의 상태를 물어보므로 그러함."* + +**무엇이 어긋나나**: 같은 항목의 **앞부분은 정확하다** — *"선언 안 한 +Source를 클로저로 읽는 건 옛 모델에서도 똑같이 stale이었고 이 변경이 +악화시키는 게 없다"*. 그런데 이어지는 인용은 그 예외를 지우고 **무조건** +으로 읽힌다. 실제로는 순회(`:Refresh()`)가 훑는 대상이 `valueEpochMap`이 +추적하는 키뿐이므로, **선언 안 된 상류는 영원히 안 보인다.** + +그리고 이건 이론적 예외가 아니라 **확정된 기능이 의존하는 성질**이다 — +`base/tween-plan.md`의 "`Animate` 콤비네이터 — 확정" 절이 +*"`Style`/`Override` 등이 State여도 값 변경 자체가 재애니메이션을 트리거하지 +않는다 … `info.Style`이 State여도 이 내부 `:Compute`의 trailing deps로 안 +[들어간다]"*로 못박았다. 즉 `Animate`는 **일부러** 미선언 읽기를 쓴다. + +**왜 고치는 게 좋은가**: 이 문장을 그대로 믿고 사용자 문서에 +"`:Get()`은 항상 최신"이라고 쓰거나, 구현자가 "그럼 읽은 걸 동적으로 추적해야 +한다"고 판단하면 `Animate`의 확정 동작이 깨진다. **결론은 안 바뀐다**(UB +조항을 안 만든다는 결정은 그대로 유효) — 근거 문장만 *"선언한 의존성에 +대해서는 항상 최신"*으로 좁히고, 미선언 읽기가 **의도적으로 쓰이는 자리**로 +`Animate`를 가리키면 된다. + +--- + +## 🟢 `H-92` — 구독자 스냅샷이 emit마다·노드마다 배열 하나를 할당한다 + +**어디**: `ROADMAP.md` M2의 `H-23` 항목(*"State 전파 루프는 구독자 집합을 +**배열로 스냅샷한 뒤** 돈다"*), `base/state-epoch-plan.md` §2의 마지막 항목 +(테이블 identity 리비전 기각 근거)과 "7. 비용" 절. + +**무엇이 어긋나나**: §2는 리비전을 숫자로 둔 근거로 *"테이블안은 **`Set` +한 번마다 테이블 하나를 할당**해서, 트윈처럼 매 프레임 `Set`하는 소스가 +여럿이면 GC 압력을 만든다(quad는 GC-native 아키텍처라 이 축을 신경 써왔다)"* +를 든다. 그런데 `H-23`이 확정한 스냅샷은 **`Set` 한 번마다 하나가 아니라, +그 파동이 지나가는 노드 수만큼** 테이블을 할당한다 — 기각된 안보다 엄격히 +더 많다. "7. 비용" 절은 이 할당을 아예 세지 않는다(맵 둘의 크기와 메소드 +디스패치만 센다). + +`H-69`가 게이트에 대해 같은 지적을 했는데(통과 모드 게이트가 emit마다 +weak 테이블 하나), 이쪽은 **게이트만이 아니라 전 노드**라 규모가 다르다. + +**왜 🟢인가**: 정확성 문제가 아니고, `H-23`의 스냅샷 자체는 실측 근거가 +있는 확정이라 되돌릴 것도 아니다. 다만 **근거끼리 어긋난 채로 두면 안 +된다** — 둘 중 하나다. (a) 이 정도 할당은 감당 가능하다고 판단하고 §2의 +GC 근거를 그에 맞게 완화한다(그러면 테이블 리비전 기각 근거는 사용자가 +든 "native call이 빠르다" 쪽만 남는다). (b) 스냅샷을 매번 새로 만들지 않는 +구현을 M2에서 같이 정한다 — 예: 구독자 집합에 세대 번호를 두고 순회 중 +추가된 것만 건너뛰기, 또는 재사용 버퍼(전파가 동기 DFS라 깊이만큼만 +필요하다). 어느 쪽이든 **구현 시 정하면 되는 것**이고, 지금 필요한 건 +§2/§7의 서술을 실제와 맞추는 것뿐이다. + +--- + +## 🟢 `H-93` — 중간 State GC 미해결은 "전파 끊김"만이 아니라 "최신이라고 오판"으로도 나타난다 + +**어디**: `base/state-epoch-plan.md` §3의 *"키는 weak다. `epoch`가 죽으면 +항목이 사라진다"*, `base/source-state-plan.md`의 "미해결 — 중간 State가 +살아남는가" 절. + +**미리 밝힘**: 이건 `question.md` 최우선 항목의 **파생**이라 이 라운드의 +검사 대상이 아니다(1차 패스가 명시적으로 뺐다). 그래도 적는 이유는 그 +항목이 지금까지 **한 가지 실패 모드**로만 서술돼 있어서다 — *"중간 State가 +수거되고 전파가 조용히 끊길 수 있다"*. + +**두 번째 실패 모드**: `valueEpochMap`의 키는 weak이고, §4는 순회를 +*"자기가 이미 들고 있는 키 전부를 라이브로 다시 읽어"* 도는 것으로 +확정했다. 그래서 추적하던 루트 `Epoch`가 수거되면 그 항목이 조용히 사라지고, +`:Refresh()`는 **남은 키만 보고 `false`를 반환**한다 → 그 노드는 +`rawInvalid`가 거짓이므로 **옛 캐시를 최신이라고 확신하고 반환**한다. +§4가 시딩을 비워두면 안 되는 이유로 든 것과 정확히 같은 오판(*"비어 있으면 +'훑을 게 없으니 유효하다'로 오판한다"*)이, 시딩이 아니라 **수거**를 통해 +런타임에 다시 생긴다. + +**그래서 실측 스파이크의 요구가 하나 늘어난다**: 지금 `ROADMAP.md`가 +예고한 스파이크는 "상류 strong / 하류 weak 불변식"의 **생존**만 본다. 거기에 +**"루트 `Epoch`가 수거된 뒤 `:Get()`이 무엇을 반환하는가"**를 같이 넣어야 +한다 — 전파가 끊기는 건 관측하기 쉽지만(아무 일도 안 일어남), 이쪽은 +**틀린 값이 정상적으로 반환**되므로 훨씬 늦게 발견된다. + +--- + +## 부록 — 4차 패스에서 돌려봤는데 **문제가 없던 것** + +참조 구현으로 같이 돌려본 것 중 문서와 일치했던 것들. 다음 라운드가 같은 +곳을 다시 파지 않도록 남긴다. + +- **다이아몬드 glitch가 실제로 사라진다** — `state-epoch-plan.md` §1이 + 예고한 그 그림(`A→B→D`, `A→C→D`, `D` 아래 Observer)을 확정된 규칙대로 + 돌리면 **Observer가 변경당 정확히 1회** 울고, 관측되는 값도 섞이지 + 않는다(`A:Set(2)`에 `220` 하나만 관측 — 옛 모델이 냈다는 `(B_new, C_old)` + 중간값이 안 나온다). `C`가 아직 emit을 못 받은 채 `D`가 재계산될 때 + `C`의 순회(`:Refresh()`)가 스스로 낡음을 알아채는 경로가 실제로 + 동작한다. 뒤늦게 도착하는 `C` 쪽 전파는 규칙 3으로 접힌다. **이게 이 + 패스의 참조 구현이 문서를 제대로 옮겼다는 대조군이기도 하다.** +- **유한한 재진입이 안전하다**(`gate-plan.md` 6번의 확정) — 전파 도중 + Observer가 동기적으로 상류를 `:Set()`해도 관측 순서가 `220, 330`으로 + 정상이고, 최종 값도 맞고, 통지가 유실되거나 중복되지 않는다. emit이 + 리비전을 **싣지 않고 받는 쪽이 라이브로 읽는다**는 §5의 결정이 여기서 + 값을 한다 — 안쪽 파동이 먼저 갱신해두면 바깥 파동의 남은 갈래가 규칙 3에 + 자연히 걸린다. +- **`Blocker` 정책의 leading/trailing 배선 자체는 성립한다** — + `gate-plan.md` 5번의 스케치(`local pass = b:Policy(emit)` 뒤 정책이 + `On()`/`Off()`만 조종)로 스로틀의 통과 시점이 `debounce-throttle-plan.md` + 1-1절 그림과 정확히 일치한다. **단 정책이 `pending`을 자기가 들 때만** + 이다(`H-86`). +- **게이트의 "빈 배치는 아무것도 안 함"(8번)이 실제로 값을 한다** — + 정책이 판단 없이 `b:Off()`를 불러도 쌓인 게 없으면 조용히 no-op이라, + 정책 쪽에 "지금 내보낼 게 있나" 가드를 안 넣어도 된다(그 가드를 넣을 + 방법이 없다는 게 `H-86`이다). +- **`Store`의 lazy `__index`가 만드는 `Source`의 identity가 안정적이면 + 에포크 모델이 성립한다** — `store-plan.md`가 *"없으면 그 자리에서 만들어 + **저장**한 뒤 반환"*, *"이후 재접근은 재생성 없이 그대로 반환"*으로 + 이미 못박아 뒀다. `EpochMap`이 `Source`를 키로 쓰는 이상 이 문장이 + load-bearing이라 확인했는데, 문제 없다. +- **`GateNode`가 `State.luau` 소속이라 순환 require가 안 생긴다** — + `state:Gate`가 State 메소드라 별도 파일이면 `State` ↔ `GateNode` 순환이 + 될 뻔했는데, `base/architecture.md`의 소스 트리가 *"`:Gate`(`GateNode` + …) 전부 여기 소속"*으로 이미 State.luau 안에 두고 있다. `Blocker.luau`도 + `state:Block`이 `b:Policy`만 부르므로 State→Blocker 의존이 안 생긴다. + +--- + ## 회신 방법 6라운드와 같다 — 항목 번호로 결정만 적어주면 `-followup.md`를 만들고 @@ -1535,6 +2142,19 @@ false invalid argument #1 to 'clone' (table expected, got nil) 같은 뿌리(`Epoch` 일반화 때 게이트 쪽 요구가 표면에 덜 반영됨)라 그것과 같이 결정하는 게 낫다. +**4차 패스**: `H-88`/`H-89`는 **한 질문의 두 얼굴**이다 — "예외가 나면 부기를 +어디까지 되돌리는가". 지금 코퍼스에 그 문장이 하나도 없어서 어느 쪽도 안 +정하면 구현자가 임의로 정하게 되고, `H-87`이 그 임의 결정이 실제로 얼마나 +조용히 아픈지 보여주는 사례라 **셋을 같이 보는 게 낫다**. `H-85`는 다른 +것과 독립이고 **가장 급하다** — 확정된 의사코드 순서 그 자체의 결함이라 +그대로 구현하면 캐시가 한 세대씩 조용히 어긋나고, 고치는 건 줄 순서 하나다. +`H-86`은 `H-55`와 **같이** 결정할 것(뿌리가 같다 — 정책이 노드 상태에 닿는 +통로가 없다); 갈래 (a)(`emit()`이 boolean 반환)를 고르면 `H-55`의 (b)와 +한 번에 닫힌다. `H-90`은 계약 결정이라 답이 "이대로 맞다"여도 되고, 그 경우 +할 일은 문서화다. `H-91`/`H-92`는 근거 문장 정정이고 결론을 안 바꾼다. +`H-93`은 이 라운드의 검사 대상 밖(`question.md` 최우선 항목의 파생)이지만, +그 항목의 실측 스파이크에 **요구가 하나 늘어난다**는 뜻이라 같이 적었다. + **3차 패스**: `H-77`은 **`H-71`과 반드시 같이** 볼 것 — 같은 절을 고치게 되지만 `H-71`의 해법 (b)가 여기선 안 듣는다는 게 실측으로 확인됐다. `H-78`은 설계 결정이 아니라 **작업 환경 결정**이라 다른 것들과 독립이고,