qa: 손 트레이싱 7라운드 4차 패스 — 참조 구현 실측 + 예외 경로, 발견 9건 (H-85~H-93)
1~3차가 안 쓴 각도 둘로 같은 라운드를 이어서 돌렸다.
(1) M2 코어(state-epoch §2~§5 / 전파 모델 / gate 4·8번 / Blocker)를
방어 코드 없이 문서 그대로 옮긴 참조 구현을 Luau로 짜서 시나리오를
돌렸다. 손 트레이싱이 못 보는 "여러 규칙이 동시에 걸리는 자리"가
여기서 나온다. 참조 구현의 대조군은 §1이 예고한 다이아몬드 glitch가
실제로 사라지는지 확인한 것.
(2) 예외 경로 — 반응형 문서 셋에 "error"가 0건이다. 사용자 콜백이
던졌을 때 반쯤 갱신된 부기가 무엇을 남기는지 정한 문장이 없다.
🔴 H-85 재계산 끝의 `rawInvalid = false`가 재계산 도중 도착한 무효화를
지운다 → 캐시가 다음 Set까지 영구 stale (재현 + 한 줄 수정 대조까지)
🔴 H-86 정책이 "보류분이 있는가"를 읽을 수 없어 Throttle의 창이 idle로
못 돌아온다 → leading 영구 소실 + 타이머 체인이 안 끝나 8절의
"유계 GC" 주장도 깨짐 (H-55와 별개 — 그쪽은 버리기, 이쪽은 읽기)
🔴 H-87 배치 게이팅 도중 error → 그 owner의 Blocker가 영구 On,
recompute가 조용히 영영 안 돎 (문서는 yield만 UB로 못박음)
🟡 H-88 전파 중 콜백이 던지면 나머지 구독자는 영구 침묵(값만 자가치유)
🟡 H-89 flush 중 error → 떼어낸 배치 소멸, :Sync와 전파의 순서 미정
🟡 H-90 Effect의 dedup이 루트 에포크 기준이라 dep 하나만 게이팅하면
같은 루트를 공유하는 다른 dep 때문에 게이팅이 무력화
🟢 H-91 §8의 "항상 Get이 최신"이 과한 서술 (Animate가 반례를 설계로 씀)
🟢 H-92 구독자 스냅샷이 emit마다·노드마다 배열 하나 — §2의 GC 근거와 충돌
🟢 H-93 EpochMap 키가 weak라 중간 State GC 미해결이 "최신이라고 오판"
으로도 나타남 (실측 스파이크 요구가 하나 늘어남)
부록에 "돌려봤는데 문제가 없던 것" 6건(다이아몬드 glitch 해소, 유한
재진입 안전, Blocker 정책 배선, 빈 배치 무통지, Store lazy __index의
identity 안정성, GateNode 순환 require 없음)도 같이 남겼다.
부수로 .claude/README.md의 qa-request 행에 7라운드가 아예 빠져 있던 걸
채웠다(1~3차 패스 때부터 비어 있었음).
아무것도 base/에 반영하지 않았다 — 회신 대기.
Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
1adafea53d
commit
03219af33f
2 changed files with 624 additions and 4 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +1,13 @@
|
||||||
# 구현 전 손 트레이싱 **7라운드** — M2(반응형 코어) 범위 + M2→M3 경계
|
# 구현 전 손 트레이싱 **7라운드** — M2(반응형 코어) 범위 + M2→M3 경계
|
||||||
|
|
||||||
**구성**: **패스 3개, 발견 30건(`H-55`~`H-84`).** 1차 패스는 문서 대 문서
|
**구성**: **패스 4개, 발견 39건(`H-55`~`H-93`).** 1차 패스는 문서 대 문서
|
||||||
손 트레이싱(`H-55`~`H-70`), **2차 패스는 문서가 "확인했다"고 적은 주장을
|
손 트레이싱(`H-55`~`H-70`), **2차 패스는 문서가 "확인했다"고 적은 주장을
|
||||||
실제로 `luau`/`luau-analyze`에 걸어본 실측**(`H-71`~`H-76`, 2026-08-25 추가),
|
실제로 `luau`/`luau-analyze`에 걸어본 실측**(`H-71`~`H-76`, 2026-08-25 추가),
|
||||||
**3차 패스는 (a) 실제로 커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려본
|
**3차 패스는 (a) 실제로 커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려본
|
||||||
것과 (b) 1·2차가 범위에서 뺐던 "M2를 *소비하는* 문서"**(`H-77`~`H-84`,
|
것과 (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] 발견 보고 — 아무것도 반영하지 않았다.** 판정은
|
**상태**: **[2026-08-25] 발견 보고 — 아무것도 반영하지 않았다.** 판정은
|
||||||
사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을
|
사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을
|
||||||
|
|
@ -98,6 +99,23 @@ GC 실측, `store:GetDynamic` 위치)에서 파생되는 것. 아래에서 그
|
||||||
| `H-83` | 🟢 | 확정된 Store 구현 스케치를 그대로 쓰면 **무인자 `Store()`가 `table.clone(nil)`로 크래시**한다 — 같은 문서가 `defaults`는 선택이라고 확정해뒀다 | `store-plan.md` | ✅ `table expected, got nil` |
|
| `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 | 표면 대조 |
|
| `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차 패스 — 문서 대 문서 손 트레이싱
|
# 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`를 만들고
|
6라운드와 같다 — 항목 번호로 결정만 적어주면 `-followup.md`를 만들고
|
||||||
|
|
@ -1535,6 +2142,19 @@ false invalid argument #1 to 'clone' (table expected, got nil)
|
||||||
같은 뿌리(`Epoch` 일반화 때 게이트 쪽 요구가 표면에 덜 반영됨)라 그것과
|
같은 뿌리(`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`과 반드시 같이** 볼 것 — 같은 절을 고치게
|
**3차 패스**: `H-77`은 **`H-71`과 반드시 같이** 볼 것 — 같은 절을 고치게
|
||||||
되지만 `H-71`의 해법 (b)가 여기선 안 듣는다는 게 실측으로 확인됐다.
|
되지만 `H-71`의 해법 (b)가 여기선 안 듣는다는 게 실측으로 확인됐다.
|
||||||
`H-78`은 설계 결정이 아니라 **작업 환경 결정**이라 다른 것들과 독립이고,
|
`H-78`은 설계 결정이 아니라 **작업 환경 결정**이라 다른 것들과 독립이고,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue