quad/.claude/luau-test/03-recursive-store-bind-dispatch.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

168 lines
5.8 KiB
Text

--[[
검증 대상: process(inst,k,v)/retract(inst,k,v) 기반 재귀 재-dispatch
모델(.claude/base/bind-system-plan.md "확정된 디스패치 모델" 절)이 실제
Luau 함수 재귀로 자연스럽게 짜이는지, 우선순위 스캔(isHandlable)이
기대대로 동작하는지에 대한 최소 스파이크.
배경: ROADMAP.md M0 3번째 항목 "process/retract 재귀 재-process
디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable
우선순위 스캔 포함)".
여기서는 다단 체인(retractFrom)까지는 다루지 않음 — 그건
04-dispatch-chain-retractFrom.luau가 별도로 다룸(단일 owner 슬롯
추적이 왜 깨지는지까지 포함). 이 파일은 "재귀 자체가 도는가", "우선순위
스캔이 맞는 핸들러를 고르는가", "None -> nil 재디스패치가 다음
핸들러로 자연히 좁혀지는가"까지만 검증.
실행: `luau 03-recursive-store-bind-dispatch.luau`
참고(2026-08-09 세션 갱신 반영): 아래 makeStore의 `subscribe(fn)`은
이 스파이크 전용으로 단순화한 것 — 실제 base 설계는 StoreBind가
`state:Observer(fn)` + `bindLifetime(inst, observer)`/
`unbindLifetime(inst, observer)`(`.claude/base/lifecycle-pattern.md`)
조합으로 구독/해제한다. 여기서 검증하려는 건 그 구독 배관이 아니라
"우선순위 스캔+재귀 process/retract 자체가 Luau에서 잘 도는가"라서
영향 없음 — 실제 Handler 구현 짤 때는 subscribe 대신 저 조합을 쓸 것.
]]
local None = setmetatable({}, { __tostring = function()
return "<None>"
end })
-- 아주 단순화된 "Store" 시늉 — 실제로는 Source/State가 되겠지만 여기선
-- 그냥 값+구독자 리스트를 가진 테이블
local StoreTag = {}
local function isStoreLike(v)
return type(v) == "table" and v[StoreTag] == true
end
local function makeStore(initial)
local self = { [StoreTag] = true, value = initial, subscribers = {} }
function self.get(_self)
return self.value
end
function self.set(_self, v)
self.value = v
for _, fn in self.subscribers do
fn(v)
end
end
function self.subscribe(_self, fn)
table.insert(self.subscribers, fn)
end
return self
end
-- Dispatch 최소 스파이크
local Dispatch = {}
local handlers = {}
function Dispatch.addHandler(handler)
table.insert(handlers, handler)
table.sort(handlers, function(a, b)
return a.priority > b.priority
end)
end
function Dispatch.getHandler(inst, k, v)
for _, h in handlers do
if h.isHandlable(inst, k, v) then
return h
end
end
return nil
end
function Dispatch.process(inst, k, v)
local h = Dispatch.getHandler(inst, k, v)
if h then
print(string.format(" [Dispatch.process] inst=%s k=%s -> handler=%s", tostring(inst), tostring(k), h.name))
h.process(inst, k, v)
else
print(string.format(" [Dispatch.process] inst=%s k=%s -> 매치되는 핸들러 없음!", tostring(inst), tostring(k)))
end
end
-- 핸들러 1: NoneHandler (해시 파트 전용, 매우 높은 우선순위)
Dispatch.addHandler({
name = "NoneHandler",
priority = 1000,
isHandlable = function(inst, k, v)
return v == None
end,
process = function(inst, k, v)
Dispatch.process(inst, k, nil) -- 재귀 재호출
end,
retract = function() end,
})
-- 핸들러 2: StoreBind (store-like 값을 잡아 재귀 재-dispatch)
Dispatch.addHandler({
name = "StoreBind",
priority = 900,
isHandlable = function(inst, k, v)
return isStoreLike(v)
end,
process = function(inst, k, store)
local function reprocess(realv)
print(
string.format(
" [StoreBind] %s.%s 값 변경 감지 -> 재귀 process(realv=%s)",
tostring(inst),
tostring(k),
tostring(realv)
)
)
Dispatch.process(inst, k, realv)
end
store:subscribe(reprocess)
reprocess(store:get()) -- 최초 1회 적용 (state:Observer의 "등록 즉시 1회 실행"을 흉내)
end,
retract = function(inst, k, v)
print(string.format(" [StoreBind.retract] %s.%s 구독 해제(흉내)", tostring(inst), tostring(k)))
end,
})
-- 핸들러 3: PropertyHandler (catch-all, 가장 낮은 우선순위)
Dispatch.addHandler({
name = "PropertyHandler",
priority = 0,
isHandlable = function()
return true
end,
process = function(inst, k, v)
print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v)))
end,
retract = function() end,
})
print("=== 1. Store 값을 프로퍼티에 바인드 ===")
local colorStore = makeStore("red")
Dispatch.process("Frame1", "BackgroundColor", colorStore)
print()
print("=== 2. Store 값 변경 -> 재귀 재-dispatch로 실제 값이 다시 세팅되는가 ===")
colorStore:set("blue")
print()
print("=== 3. None 센티널 -> nil로 재귀 재-dispatch되어 PropertyHandler로 흘러가는가 ===")
Dispatch.process("Frame1", "Rotation", None)
print()
print("=== 4. 무한 재귀 없이 종료되는가 ===")
print("위 1~3에서 스택 오버플로/무한 루프 없이 정상 종료됐다면 통과")
--[[
확인 포인트:
1. 콘솔에 handler=StoreBind가 먼저 찍히고, 그 다음 재귀로
handler=PropertyHandler가 찍히는가?
2. colorStore:set("blue") 이후 PropertyHandler가 다시(blue로) 불리는가?
3. None 케이스가 PropertyHandler까지 자연스럽게 흘러가는가(중간에
NoneHandler가 한 번만 관여하고 끝나는가)?
4. table.sort 기반 우선순위 스캔이 매번 안정적으로 같은 순서를 내는가
(Luau table.sort는 unstable sort일 수 있음 — 동일 priority 핸들러가
여러 개면 순서가 실행마다 바뀔 수 있다는 점 주의. 실제 구현에서는
priority를 세밀하게 나누거나 등록 순서를 tie-breaker로 쓰는 걸
검토할 가치가 있어 보임 — 지금 base 문서엔 이 tie-break 규칙이
명시돼 있지 않음, 실제로 문제가 되면 base/bind-system-plan.md에
추가할 것).
]]