quad/.claude/luau-test/04-dispatch-chain-retractFrom.luau
qwreey 8c784288b0
audit(dispatch,slot): c33ae04 전체 감사 — 실제 버그 4건 수정, Slot 언마운트 전환, 재디스패치 모델 재설계안
c33ae04(인덱스 기반 Dispatch 재설계) 전체를 직접 정독. 방향은 옳으나 새로 쓴
의사코드가 손 트레이싱을 안 거친 채 커밋됐음이 드러나 실제 버그 4건 발견·수정:

1. Dispatch.process가 chains:SetStrong을 h.process 뒤에 둬서 최초 마운트에서
   하위 위임 retractor가 통째로 유실(재귀가 자기 테이블을 만들었다 바깥이
   덮어씀 — Slot이면 서브트리 중복 마운트로 직결). SetStrong hoist + 재귀 중
   hole 방지용 no-op 점유 마커 추가.
2. Attribute 그룹이 process에서 retractFrom을 선행 호출해 인덱스 1이 무조건
   비워지는 바람에 점유 체크(=소유권 충돌 감지)가 전혀 작동 안 함. 철거를
   반환 클로저로 이동.
3. SlotHandler가 claim 실패에도 파괴적 클로저를 반환 — nested는 엄격
   claimOwner, top-level은 claimOwnerAt(inst,k)로 분리. rawRemove의
   releaseOwner 누락, destroySlotTree의 GC 타이밍 의존 오류도 수정.
4. Ref retractor가 spurious 재발행에서도 relate를 지워 dedup 무력화.

사용자 설계 결정:
- State<Slot> 교체 = 파괴가 아니라 언마운트(state<Frame>와 동일). 포탈이
  별도 기능이 아니라 이 결정의 귀결이 됨. 해제는 setOffsetSource(None) →
  setLength(0) 순서 고정(반대면 죽는 중인 서브트리 Source에 헛된 Set이
  날아감). dispose(value) 신설 — 트리가 아직 요구하면 파괴 거부하고 error.
- 재디스패치를 "하강 diff"로 재설계 — 래핑 핸들러의 retractFrom 선행 호출을
  폐기하고 Dispatch.process가 핸들러를 먼저 비교. 힌트가 None/래퍼로 오염돼
  깜빡임 방지가 조용히 꺼지는 결함이 계기. 아직 research/에만 있고 base
  미반영(Attribute 이름 소유권 하나가 남음) — base 4개 문서에 경고 배너.

전파 누락 정리: ROADMAP.md가 2026-08-08 이전 모델로 남아있던 것, base 내
"3종 vs 4종 계약" 모순 6개 문서, luau-test/04가 없어진 가드를 검증하던 것
(04는 인덱스 모델 + 버그 1 재현 음성 대조군으로 전면 재작성).

재발 방지: bind-system-plan.md "Handler 작성 체크리스트"(7항목),
relate-plan.md "언제 Relate를 쓰고 언제 쓰면 안 되는가" 신설.

다음 세션 최우선: question.md 0-Z(Attribute 이름 소유권) — 사용자가 직접
스케치하며 심층 분석 예정.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:01:49 +09:00

306 lines
11 KiB
Text

--[[
검증 대상: 인덱스 기반 `Dispatch` 체인(2026-08-13 다섯 번째 세션 전면
재설계, `.claude/base/bind-system-plan.md` "Dispatch 체인" 절)이 다단
재귀 위임에서 실제로 정확한지.
새 모델의 요점(옛 모델과 뭐가 다른지):
- `chains[inst][k]`는 **핸들러 배열이 아니라 `{[index] = retractor}`**.
index = 같은 키 안에서의 재귀 깊이(같은 키 재귀는 index+1, 다른
키로 위임하면 그 키에서 다시 1부터).
- Handler 계약이 `process`/`retract` 2-메소드에서 **`process`가 자기
retract 클로저를 반환하는 1-메소드**로 축소.
- `Dispatch.retractFrom(inst,k,index,v)` 하나가 옛
`retractUnder`/`retractSelfAndUnder` 둘을 대체(자기 포함/미만은
호출자가 넘기는 인덱스로 표현).
**이 파일은 2026-08-13 감사에서 전면 재작성됨.** 이전 버전
(`04-dispatch-chain-retractUnder.luau`)은 핸들러 **객체 identity**로
체인을 추적하던 옛 설계와, 그 위에 얹혔던 "같은 (inst,k)에 같은 핸들러
중복 push면 즉시 error" 가드를 검증하고 있었는데 — 그 가드는 인덱스
기반 재설계로 **없어졌고**, `State<State<T>>`는 UB가 아니라 정상 지원
대상으로 재정정됨. 즉 옛 파일은 설계와 정반대를 테스트하고 있었음.
검증 항목:
A. 3단 체인(StoreBind -> StoreBind -> Property)이 인덱스 1/2/3으로
안 겹치고 쌓이는가 (= `State<State<T>>` 정상 동작)
B. 안쪽 store 재발행 시 index 3만 갈리고 1/2는 유지되는가
C. 바깥 store 재발행 시 index 2/3이 **깊은 쪽부터** 정리되는가
D. `retractFrom`의 hint(`v`)가 정확히 target index에만 전달되는가
E. **[감사 회귀 테스트]** `chains:SetStrong`을 `handler.process`
*뒤에* 두면 최초 마운트에서 하위 retractor가 유실되는가
(음성 대조군 — 이 버그가 실재함을 증명, 2026-08-13 감사에서 발견)
실행: `luau 04-dispatch-chain-retractFrom.luau`
캐비엇: 아래 `subscribe(fn)`은 이 스파이크 전용 단순화 — 실제로는
`state:Observer(fn)` + `bindLifetime`/`unbindLifetime` 조합
(`.claude/base/lifecycle-pattern.md`). 다만 **이 스파이크의 retractor는
no-op 스텁이 아니라 실제로 구독을 끊는다** — 옛 04번이 no-op print
스텁이라 체인 파손을 못 잡는 사각지대였던 걸 그대로 반복하지 않기 위함.
]]
local log = {}
local function say(fmt, ...)
local line = string.format(fmt, ...)
table.insert(log, line)
print(line)
end
--==========================================================================
-- 아주 작은 Store/State 스텁
--==========================================================================
local StoreMT = {}
StoreMT.__index = StoreMT
local function Store(value, name)
return setmetatable({ _v = value, _subs = {}, _name = name }, StoreMT)
end
local function isState(v)
return type(v) == "table" and getmetatable(v) == StoreMT
end
function StoreMT:Get()
return self._v
end
-- 구독. 반환값 = 구독 해제 함수(실제 구현의 unbindLifetime 자리)
function StoreMT:subscribe(fn)
local token = {}
self._subs[token] = fn
return function()
self._subs[token] = nil
end
end
function StoreMT:Set(v)
self._v = v
for _, fn in pairs(self._subs) do
fn()
end
end
function StoreMT:subCount()
local n = 0
for _ in pairs(self._subs) do
n += 1
end
return n
end
--==========================================================================
-- Dispatch — 인덱스 기반. storeFirst 플래그로 올바른 순서/버그 순서를 전환
--==========================================================================
local NOOP = function() end
local function makeDispatch(opts)
local storeChainBeforeProcess = opts.storeChainBeforeProcess
local chains = {} -- {[inst] = {[k] = {[index] = retractor}}}
local D = {}
local handlers = {}
function D.addHandler(h)
table.insert(handlers, h)
table.sort(handlers, function(a, b)
return a.priority > b.priority
end)
end
function D.getHandler(inst, k, v)
for _, h in ipairs(handlers) do
if h.isHandlable(inst, k, v) then
return h
end
end
error(string.format("Dispatch: 매치되는 핸들러 없음 (k=%s, v=%s)", tostring(k), tostring(v)))
end
function D.process(inst, k, v, index)
local byKey = chains[inst]
if not byKey then
byKey = {}
chains[inst] = byKey
end
local list = byKey[k]
if not list then
list = {}
if storeChainBeforeProcess then
byKey[k] = list -- 올바른 순서: handler.process 호출 전에 등록
end
end
if list[index] ~= nil then
error(string.format("Dispatch: (inst,%s) 인덱스 %d는 이미 점유됨", tostring(k), index))
end
list[index] = NOOP -- 점유 마커(재귀 중 hole 방지 + 같은 index 재진입 가드)
local h = D.getHandler(inst, k, v)
list[index] = h.process(inst, k, v, index)
if not storeChainBeforeProcess then
byKey[k] = list -- 버그 순서: 재귀가 만든 별도 테이블을 덮어씀
end
end
function D.retractFrom(inst, k, index, v)
local byKey = chains[inst]
local list = byKey and byKey[k]
if not list then
return
end
for i = #list, index, -1 do
local retractor = list[i]
if retractor then
retractor(if i == index then v else nil)
end
list[i] = nil
end
end
function D.depth(inst, k)
local byKey = chains[inst]
local list = byKey and byKey[k]
return if list then #list else 0
end
return D
end
--==========================================================================
-- 핸들러 두 개 — StoreBind(재귀 위임) / Property(말단)
--==========================================================================
local applied = {} -- inst.k -> 마지막으로 실제 세팅된 값
local hintSeen = {} -- 각 핸들러가 자기 클로저에서 받은 hintValue 기록
local function makeHandlers(D)
local StoreBind = {
priority = 100,
isHandlable = function(_, _, v)
return isState(v)
end,
}
function StoreBind.process(inst, k, state, index)
local unsubscribe = state:subscribe(function()
local realv = state:Get()
say(" [StoreBind idx=%d] %s 재발행 -> retractFrom(idx=%d) 후 재위임", index, state._name, index + 1)
D.retractFrom(inst, k, index + 1, realv)
D.process(inst, k, realv, index + 1)
end)
-- 등록 즉시 1회(실제 Observer 계약과 동일)
local realv = state:Get()
D.retractFrom(inst, k, index + 1, realv)
D.process(inst, k, realv, index + 1)
return function(hintValue)
table.insert(hintSeen, string.format("StoreBind(%s)@%d hint=%s", state._name, index, tostring(hintValue)))
say(" [StoreBind idx=%d] retractor 호출 — %s 구독 해제 (hint=%s)", index, state._name, tostring(hintValue))
unsubscribe()
end
end
local Property = {
priority = 1,
isHandlable = function()
return true -- 최하위 폴백
end,
}
function Property.process(inst, k, v, index)
applied[inst .. "." .. k] = v
say(" [Property idx=%d] %s.%s = %s", index, inst, k, tostring(v))
return function(hintValue)
table.insert(hintSeen, string.format("Property@%d hint=%s", index, tostring(hintValue)))
say(" [Property idx=%d] retractor 호출 (hint=%s)", index, tostring(hintValue))
end
end
D.addHandler(StoreBind)
D.addHandler(Property)
end
--==========================================================================
-- 시나리오 실행기
--==========================================================================
local function runScenario(label, storeChainBeforeProcess)
say("")
say("=====================================================")
say("%s (chains 저장 순서: %s)", label, if storeChainBeforeProcess then "process 前(올바름)" else "process 後(버그 재현)")
say("=====================================================")
applied, hintSeen = {}, {}
local D = makeDispatch({ storeChainBeforeProcess = storeChainBeforeProcess })
makeHandlers(D)
local inner = Store("v1", "inner")
local outer = Store(inner, "outer")
say("")
say("[1] 최초 마운트 — outer(State<State<string>>)를 인덱스 1로 진입")
D.process("Frame", "Text", outer, 1)
say(" -> 체인 깊이 = %d (기대: 3 — StoreBind/StoreBind/Property)", D.depth("Frame", "Text"))
say(" -> Frame.Text = %s (기대: v1)", tostring(applied["Frame.Text"]))
say(" -> outer 구독 %d개 / inner 구독 %d개 (기대: 1 / 1)", outer:subCount(), inner:subCount())
say("")
say("[2] 안쪽 store 재발행 — index 3만 갈리고 1/2는 유지돼야 함")
inner:Set("v2")
say(" -> 체인 깊이 = %d (기대: 3)", D.depth("Frame", "Text"))
say(" -> Frame.Text = %s (기대: v2)", tostring(applied["Frame.Text"]))
say(" -> outer 구독 %d개 / inner 구독 %d개 (기대: 1 / 1 — 둘 다 살아있어야)", outer:subCount(), inner:subCount())
say("")
say("[3] 바깥 store 재발행(새 inner2) — index 2/3이 깊은 쪽부터 정리돼야 함")
local inner2 = Store("w1", "inner2")
outer:Set(inner2)
say(" -> 체인 깊이 = %d (기대: 3)", D.depth("Frame", "Text"))
say(" -> Frame.Text = %s (기대: w1)", tostring(applied["Frame.Text"]))
say(
" -> outer %d / inner(옛) %d / inner2 %d (기대: 1 / 0 / 1 — 옛 inner 구독이 끊겨야)",
outer:subCount(),
inner:subCount(),
inner2:subCount()
)
say("")
say("[4] 옛 inner를 다시 건드려도 아무 일도 없어야 함(구독이 끊겼으므로)")
inner:Set("STALE")
say(" -> Frame.Text = %s (기대: w1 — STALE로 안 바뀌어야)", tostring(applied["Frame.Text"]))
say("")
say("[5] 전체 철거 — retractFrom(index=1)")
D.retractFrom("Frame", "Text", 1, nil)
say(" -> 체인 깊이 = %d (기대: 0)", D.depth("Frame", "Text"))
say(" -> outer %d / inner2 %d (기대: 0 / 0)", outer:subCount(), inner2:subCount())
say("")
say("[hint 전달 기록] retractFrom(idx,v)의 v가 target index에만 가는지:")
for _, l in ipairs(hintSeen) do
say(" %s", l)
end
end
runScenario("A~D. 정상 설계", true)
runScenario("E. 음성 대조군 — chains 저장을 process 뒤로 옮기면", false)
say("")
say("=====================================================")
say("판정 가이드")
say("=====================================================")
say([[
* 첫 번째(정상) 시나리오에서 [1]의 체인 깊이가 3이 아니면 — 인덱스
누적 자체가 틀린 것. base 문서의 index+1 규칙부터 다시 볼 것.
* [3]에서 "inner(옛) 구독 0"이 안 나오면 — 깊은 인덱스부터 정리하는
retractFrom 순회가 깨진 것. [4]에서 STALE이 반영되는 것으로도 같은
증상이 드러남.
* 두 번째(음성 대조군) 시나리오에서 [1]의 체인 깊이가 **1**로 나오면 —
2026-08-13 감사가 지적한 버그가 재현된 것: 재귀 위임이 자기만의
list 테이블을 만들어 저장했다가 바깥 process가 그걸 덮어써서 index
2/3의 retractor가 통째로 유실됨. 이어서 [3]에서 옛 inner 구독이
안 끊기고 [4]에서 STALE이 반영되면 그 유실이 실제 동작 차이로
이어진다는 확인.
* 대조군이 정상 시나리오와 똑같이 나온다면(= 버그가 재현 안 되면)
이 스파이크의 모델링이 실제 구현과 어긋난 것이니, 결론을 내리기
전에 스파이크 쪽을 먼저 의심할 것.
]])