quad/.claude/luau-test/04-dispatch-chain-retractUnder.luau
qwreey 6e9f6fefa1
fix(dispatch): State<State<T>> 재진입 디스패치 체인 파손 버그 발견·수정, Operator Alternative 후보 신설
Haskell Monad/Applicative 비교 리서치 중 사용자가 retractUnder의 꼬리부터
cutoff 로직을 재검토하다 제기한 의심을 pseudocode 손 트레이싱으로 확인 —
store가 emit하는 값 자체가 또 State/Source면 같은 (inst,k)에 같은 핸들러가
중복 push되어 안쪽 구독이 등록 직후 스스로 끊기는 실제 체인 파손 버그였음.
Dispatch.process에 중복 핸들러 즉시 error 가드를 추가하고, 같은 시나리오를
이미 다루고 있었지만 no-op retract 스텁 때문에 증상을 못 잡던 luau-test/04의
사각지대도 재작성. operator-sugar-plan.md에 Alternative(nil 대체값) 후보 신설.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:49:34 +09:00

253 lines
10 KiB
Text

--[[
검증 대상: Dispatch가 (inst,k)별 핸들러 체인을 배열로 소유하고,
retractUnder(inst,k,keep,v)가 꼬리부터 keep 앞까지 정리하는 설계
(.claude/base/bind-system-plan.md "Dispatch 체인" 절)가 다단 체인
(A->B->C)에서 실제로 정확한지 검증.
배경: 2026-08-08 세 번째 세션 — "전역 소유자 슬롯 하나"로 추적하는
1차 설계가 재귀/래핑 핸들러(A가 B로 위임하는데 A 자신도 나중에
재계산되는 경우)에서 깨지는 걸 반례로 확인하고 체인 방식으로 교체함.
CLAUDE.md는 "M2/M4 스파이크 검증 목록에 chains/retractUnder가 다단
체인에서 실제로 정확히 동작하는지가 새로 추가됨(추론만으로 확정된 것)"
이라고 명시 — 아직 실제 Luau로 돌려본 적 없음. 이 파일이 그 검증.
시나리오: StoreA(바깥 store) -> StoreBind가 잡아서 그 값을 다시
Dispatch.process로 재귀 -> 그 값이 또 다른 Store(StoreB, "이중 store"
케이스를 흉내)일 때 두 번째 StoreBind가 또 잡아서 재귀 -> 최종적으로
PropertyHandler가 실제 세팅. 즉 A(StoreBind)->B(StoreBind again)->C(Property)
3단 체인. ("Store가 Store를 담지 않는다"가 설계상 확정이라 이 자체는
UB에 가까운 입력이지만, 체인 메커니즘이 다단에서 실제로 버티는지는
그것과 별개로 확인해둘 가치가 있어 일부러 스트레스 테스트로 씀.)
실행: `luau 04-dispatch-chain-retractUnder.luau`
참고(2026-08-09 세션 갱신 반영): 03번과 동일하게 아래 `subscribe(fn)`은
이 스파이크 전용 단순화 — 실제로는 `state:Observer(fn)` +
`bindLifetime`/`unbindLifetime` 조합(`.claude/base/lifecycle-pattern.md`)을
쓴다. `retract`가 할 일이 "구독 해제"라는 본질은 같아서 체인/
retractUnder 로직 검증엔 영향 없음.
**[2026-08-13 세션 정정]** 바로 위 문단의 마지막 문장은 3단계~4단계
(store-in-store, 즉 `State<State<T>>`에 대응하는 시나리오)에 대해서는
틀렸음이 드러남 — 이 스파이크의 `retract`는 print만 하는 완전 no-op이라
"자기 자신을 스스로 retract하는" 버그가 실제로 일어나도 아무 부작용
없이 넘어가버려서, 겉보기엔(체인 길이만 보면) 3~4단계가 정상으로
보임. 실제로는 같은 싱글톤 `StoreBindA` 핸들러가 같은 `(inst,k)`에
두 번 push되고(`list={StoreBindA,StoreBindA}`), `retractUnder`의
457행 "첫 매치 cutoff" 로직이 안쪽 재계산 시점에도 항상 **바깥쪽**
인덱스를 cutoff로 잡아버려 안쪽 자기 자신이 잘못 retract되는 게
`.claude/base/bind-system-plan.md` "확정된 디스패치 모델" 절
2026-08-13 항목에서 손으로 트레이싱해 확인됨 — 실제 `bindLifetime`
기반 구현이었다면 안쪽 State 구독이 등록 직후 끊겨 이후 갱신이
조용히 무시됐을 것. 이 파일이 "다단 체인이 정상"이라고 결론 내린 건
이 스텁의 사각지대 때문이었지 실제로 검증된 게 아니었음 — 그래서
아래에 같은 `(inst,k)`에 같은 핸들러가 중복 push되면 즉시 error하는
가드(같은 세션에 base 문서 pseudocode에도 추가됨)를 넣고, 3단계에서
이 가드가 실제로 걸리는지 확인하는 걸로 대체.
]]
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
-- Relate 대용 (weak-key까지는 이 스파이크에서 안 다룸, 순수 로직 검증이 목적 —
-- weak-key/GC 쪽은 07-relate-weak-table-gc.luau가 따로 다룸)
local chains = {} -- [inst] = { [k] = { handler, handler, ... } }
local function chainFor(inst, k)
chains[inst] = chains[inst] or {}
chains[inst][k] = chains[inst][k] or {}
return chains[inst][k]
end
local Dispatch = {}
local handlers = {}
function Dispatch.addHandler(h)
table.insert(handlers, h)
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 not h then
print(string.format(" (매치 없음: %s.%s = %s)", tostring(inst), tostring(k), tostring(v)))
return
end
local list = chainFor(inst, k)
-- [2026-08-13 세션 신설] 같은 (inst,k)에 같은 핸들러 객체가 이미 체인에
-- 있으면 error — State<State<T>>류 재진입 디스패치를 조용히 깨지게
-- 두지 않고 즉시 실패시킴 (base/bind-system-plan.md 동시 반영)
for _, existing in list do
if existing == h then
error(
string.format(
"Dispatch: handler '%s' already active for %s.%s — re-entrant dispatch (e.g. State<State<T>>) is not supported",
h.name,
tostring(inst),
tostring(k)
)
)
end
end
table.insert(list, h)
print(string.format(" [chain push] %s.%s <- %s (체인 길이=%d)", tostring(inst), tostring(k), h.name, #list))
h.process(inst, k, v)
end
-- .claude/base/bind-system-plan.md의 pseudo code 그대로 옮김
function Dispatch.retractUnder(inst, k, keep, v)
local list = chainFor(inst, k)
local cutoff = 0
if keep then
for i, h in list do
if h == keep then
cutoff = i
break
end
end
end
for i = #list, cutoff + 1, -1 do
local retractedHandler = list[i]
local passedValue = (i == cutoff + 1) and v or nil
print(
string.format(
" [retractUnder] %s.%s: %s.retract(v=%s) 호출, 체인에서 제거",
tostring(inst),
tostring(k),
retractedHandler.name,
tostring(passedValue)
)
)
retractedHandler.retract(inst, k, passedValue)
list[i] = nil
end
end
-- 핸들러: StoreBind (self 식별을 위해 핸들러 테이블 자기 자신을 process 안에서 캡처)
local function makeStoreBindHandler(name, priority)
local self
self = {
name = name,
priority = priority,
isHandlable = function(inst, k, v)
return isStoreLike(v)
end,
process = function(inst, k, store)
local function reprocess(realv)
print(
string.format(
" [%s] %s.%s 재계산 -> retractUnder(keep=self) 먼저, 그 다음 재귀 process",
name,
tostring(inst),
tostring(k)
)
)
Dispatch.retractUnder(inst, k, self, realv)
Dispatch.process(inst, k, realv)
end
store:subscribe(reprocess)
reprocess(store:get())
end,
retract = function(inst, k, v)
print(string.format(" [%s.retract] 나 자신(구독) 정리", name))
end,
}
return self
end
Dispatch.addHandler(makeStoreBindHandler("StoreBindA", 900))
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(inst, k, v)
print(string.format(" [PropertyHandler.retract] 이전 값 무름"))
end,
})
print('=== 1단계: StoreA(값="hello") 바인드 ===')
local storeA = makeStore("hello")
Dispatch.process("Frame1", "Text", storeA)
print(" 현재 체인 길이:", #chainFor("Frame1", "Text"))
print()
print("=== 2단계: StoreA 값을 다른 일반 값으로 바꿈(체인이 A 밑을 정확히 정리하는가) ===")
storeA:set("world")
print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개여야 정상)")
print()
print("=== 3단계 [2026-08-13 세션 재작성]: StoreA 값을 store로 다시 바꿔서")
print(" (State<State<T>>에 대응) 재진입 디스패치를 유도 — 이제 가드가")
print(" 즉시 error해야 정상 ===")
local storeB = makeStore("nested")
local ok, err = pcall(function()
storeA:set(storeB)
end)
print(" error로 막혔는가:", not ok)
print(" 에러 메시지:", tostring(err))
print(
" 현재 체인 길이:",
#chainFor("Frame1", "Text"),
"(가드가 push 전에 error하므로 1이어야 정상 — StoreBindA만 남고 오염 없음)"
)
print()
print("=== 4단계: 가드가 걸린 뒤에도 같은 자리에 정상 값으로 다시 바인드하면")
print(" 문제없이 복구되는가(에러가 체인을 영구 오염시키지 않는가) ===")
storeA:set(42)
print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개로 정상 복구되어야 함)")
--[[
확인 포인트 (이게 이 파일의 핵심 목적):
1. 2단계에서 storeA:set("world") 이후 체인 길이가 정확히 2(A, Property)로
돌아오는가 — retractUnder가 이전 PropertyHandler를 정리하고 새로
push했는가, 아니면 계속 누적돼서 체인이 무한정 길어지는가?
(누적되면 버그 — 체인이 GC 안 되는 메모리 누수이자 논리 오류)
2. **[2026-08-13 세션 정정]** 3단계는 원래 "A(StoreBindA) 자신은 살아남고
그 밑만 정리되는가"를 확인하려 했으나, 이 스파이크의 `retract`가
print만 하는 no-op이라 실제 자기-retract 버그(같은 싱글톤 핸들러가
같은 (inst,k)에 중복 push되고, retractUnder의 첫-매치 cutoff가 안쪽
재계산 시점에 바깥쪽 인덱스를 잡아 안쪽이 스스로를 retract하는 것 —
상세 트레이싱은 `.claude/base/bind-system-plan.md` "확정된 디스패치
모델" 절 2026-08-13 항목)를 이 스텁으로는 절대 못 잡는다는 게 드러남.
그래서 이제 3단계는 "같은 핸들러 중복 push를 Dispatch.process가
push 전에 잡아 즉시 error하는가"를 확인 — `ok`가 `false`고 체인
길이가 오염 없이 1로 남는 게 정상.
3. 4단계: 가드가 발동한 뒤에도 같은 (inst,k)에 정상적으로 새 값을
바인드할 수 있는가(에러가 chains 자료구조를 영구히 깨뜨리지
않는가) — 체인 길이가 다시 2(A, Property)로 정상 복구돼야 함.
4. 스택 오버플로 없이 전부 정상 종료되는가.
]]