quad/.claude/luau-test/19-ownership-refcount-relate-patterns.luau
qwreey 124b706e2c
test(luau-test): 첫 실측 라운드 — 런타임 12개 전원 통과, 타입 이슈 1건 발견
luau/luau-analyze 바이너리가 사용 가능해져, 2026-08-09에 스파이크를 만들기
시작한 이래 처음으로 실제로 돌림(CLAUDE.md가 "M0 착수 전 남은 유일한
게이트"로 꼽아온 항목). 전문: .claude/audit/luau-test-first-run-2026-08-13.md

## 설계 검증 결과 — 런타임은 전부 성립

- 04가 직전 커밋의 감사에서 찾은 버그를 음성 대조군으로 재현: chains:SetStrong을
  process 뒤에 두면 체인 깊이가 3 대신 1로 무너지고, 죽은 store가 나중에 UI를
  덮어씀(STALE). 감사→수정 사이클이 실측으로 닫힘.
- 07은 보강해야 실제 검증이 됐음. 3번 섹션이 sanity check만 하고 있었고 헤더가
  내세운 연쇄 GC 주장은 미검증이었음. 파일이 스스로 적어둔 "weak table 엔트리를
  셀 표준 API가 없다"는 전제도 틀렸음(outer가 __mode="k"라 GC 후 pairs에서
  사라짐) — _countEntries + weak-value canary로 4번 섹션 신설, GC-native
  아키텍처의 핵심 전제(연쇄 GC)가 실측 확정됨.
- 18이 relate-plan.md의 상호 순환 경고를 실증 — 추측이 아니라 실제로 GC 안 됨.
- 01/02/03/05/06/20도 전부 통과.

## 타입 — 진짜 설계 이슈 1건 (question.md 0-Y 신설)

:Compute(fn)의 lazy 핸들 계약이 Luau 양방향 추론과 충돌.
`state:Compute(function(s) return s:Get() * 2 end)`가 타입 에러를 냄.
최소 재현으로 원인 확정: read/self 표기 조정으로는 안 풀리고, 콜백이 raw 값을
받으면 완전 클린. Effect/Observer/Animate/Operator 등 같은 계약을 공유하는
API 전부에 걸림 — M0 착수 전 결정 필요.

## 문서 결함 발견 — modifier-plan.md

"데이터를 테이블에 직접 두고"가 "self 최상위 리터럴 키"로 읽힐 여지가 있었는데,
그렇게 하면 __index가 rawget 성공 시 안 불려 같은 필드 재호출이 죽음. 그 재호출
패턴이 문서 3·4절의 대표 용례라 실사용에서 즉시 터지는 경로였음 — 경고 문단 추가.

## 스파이크 수정

- 17: self 최상위 키 저장 → 내부 저장소 구조로 재작성(크래시 해소)
- 11: 브랜드 판별 크래시로 "다른 Modifier" 케이스가 엉뚱하게 통과하던 것 수정
- 19: B/C를 폐기 설계(rawNew+owners, 3분기 claimOwner)에서 현행 설계로 재작성,
      음성 대조군으로 옛 로직이 Slot{a,a}/Frame{slot,slot}을 통과시킴을 재현
- 07: 위 참고

남은 것은 13/15/16의 스파이크 격리·API 재확인(설계 문제 아님).

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

499 lines
23 KiB
Text

--[[
검증 대상: "retract는 항상 불림" 전면 정정(2026-08-12 열한 번째 세션) 이후
새로 생긴 세 가지 소유권/참조카운트 추적 로직이 실제로 짜인 대로 동작하는지 —
전부 여러 위치/여러 사이클에 걸쳐 상태가 정확히 갱신되는지가 핵심이라
손으로 추론만으로는 놓치기 쉬운 클래스(이 프로젝트가 이미 같은 클래스에서
실제 버그를 두 번 냄: `retractUnder`의 and/or 삼항 falsy 버그, Slot
`recompute`의 off-by-one). 셋 다 Roblox 엔진/GC 타이밍과 무관한 순수 Luau
테이블 로직이지만, 03/04/11번 스파이크가 커버하는 것과는 다른 새 알고리즘
모양(여러 위치가 하나의 이름/자리를 공유하는 참조 카운트, 캐싱된 키 객체의
사이클 간 재사용, "같은 owner면 무시/다른 owner면 error" 분기)이라 별도로
실측이 필요하다고 판단해 신규 작성함.
[2026-08-13 재작성] B/C 섹션은 원래 폐기된 설계(`rawNew`+`owners` 수동
레지스트리, "같은 owner면 no-op" 3분기 `claimOwner`)를 검증하고 있어서
현재 base 설계에 맞춰 다시 씀. A 섹션은 그대로 유효 — 단 `kTagMap`은
2026-08-13 다섯 번째 세션에 Handler 계약이 클로저 반환으로 바뀌며
삭제됐고, 클로저가 `v`(이 위치의 Tag)를 직접 캡처하므로 이제
`tagNameMap`(이름 -> 홀더 Tag set) 하나만 남음 — 참조 카운트 로직
자체는 안 바뀌어서 A의 검증 내용/기댓값은 그대로 성립.
A) Tag 참조 카운트 — `base/tag-plan.md` "메커니즘 — TagHandler" 절의
`tagNameMap`. 서로 다른 두 위치가 같은 이름을 겹쳐 가질 때
(`Frame { Tag("a"), Tag("a","b") }`류) 한쪽이 이름을 잃어도 다른 쪽이
아직 쥐고 있으면 실제 `RemoveTag`가 안 불려야 함(웹 className 합집합
시맨틱).
B) Attribute 이름 소유권 — `base/attribute-plan.md` "이름 소유권"/
"메커니즘" 절의 최종(세 번째) 설계: 그룹이 이름마다 공개
`AttributeKey(name)`(이름별 weak 캐시)으로 항상 인덱스 1에
`Dispatch.process(inst, key, source, 1)`만 부르고(`retractFrom`
선행 호출 금지 — 부르면 점유 체크가 무력화됨), 철거는 반환 클로저가
자기가 등록한 이름 전부에 대해 수행. 소유권 충돌 감지는 별도
레지스트리가 아니라 **`Dispatch.process` 자신의 인덱스 점유 체크**가
대신함 — 이 스파이크는 그 점유 체크 로직 자체를 흉내 내어 검증.
**[중요 캐비엇]** `research/dispatch-redispatch-diff-plan.md` 5절이
지적하듯, 앞으로 도입될 "하강 diff" 재디스패치 모델(`Dispatch.process`가
핸들러를 먼저 비교해 같으면 재사용)에서는 그룹 A/그룹 B가 둘 다
`StoreBind`로 보여 "같은 핸들러"로 판정되므로 **이 점유 체크 하나만으로는
그룹↔그룹 충돌을 못 잡는다**는 게 이미 확인돼 있음(`.claude/question.md`
0-Z, 최우선 미결 항목 — "이전 결정인 이름별 claimant `Relate`를 다시
가져오는 방향"으로 사용자가 기울었으나 심층 분석은 다음 세션으로 이관).
즉 아래 B 섹션이 검증하는 건 **현재 확정된 모델**(점유 체크가 충돌을
잡음, 그룹↔직접 쓰기 케이스는 이 모델에서도 계속 유효)이고, 0-Z가
결정되면(특히 그룹↔그룹 케이스) 이 섹션도 다시 손봐야 함.
C) Slot 요소 소유권 — `base/slot-plan.md` "요소 소유권 — `elementOwner`"
절의 `claimOwner`(nested `rawAdd` 전용, 엄격 — 같은 owner 재클레임도
error)와 `claimOwnerAt`(top-level `SlotHandler` 전용 — `(inst,k)`까지
봐서 정확히 같은 자리의 spurious 재발행만 `false`, 그 외 중복은 전부
error) 두 함수로 쪼개진 최종 설계. 원래 하나의 `claimOwner`가 "같은
owner면 no-op(false)"이라는 3분기를 양쪽에 공유했던 게 감사에서 버그로
판정됨(`Frame { slot, slot }`에서 top-level이 `k=2`를 조용히 no-op
처리하고도 파괴적 클로저를 반환해 이중 파괴로 이어짐, `local a=Slot{};
Slot{a,a}`에서 nested가 반환값을 안 봐서 이중 클레임을 그냥 통과시킴) —
아래 C 섹션은 두 함수를 각각 검증.
실행: `luau 19-ownership-refcount-relate-patterns.luau`
]]
-- ===== 공용: Relate 흉내(이 스파이크는 순수 로직 검증이 목적이라 weak-key는
-- 07/18번이 이미 따로 다룸 — 여기선 plain 테이블로 단순화) =====
local function makeRegistry()
local t = {}
return {
get = function(_, a, b)
local sub = t[a]
return sub and sub[b]
end,
set = function(_, a, b, v)
t[a] = t[a] or {}
t[a][b] = v
end,
}
end
local function report(name, ok, expected, actual)
local pass = ok
print(string.format(" [%s] %s%s", pass and "PASS" or "FAIL", name, pass and "" or string.format(" (expected=%s actual=%s)", tostring(expected), tostring(actual))))
return pass
end
local allPass = true
-- ===== A. Tag 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가짐 =====
print("=== A. Tag tagNameMap — 참조 카운트 ===")
do
-- [2026-08-13 재작성 시 확인] 옛 `kTagMap`(위치별 마지막 Tag를 별도
-- 저장하던 맵)은 실제 base 설계에선 없음 — 2026-08-13 다섯 번째 세션에
-- `process`가 자기 retract 클로저를 반환하는 계약으로 바뀌며, "이
-- (inst,k) 자리에 지금 뭐가 있었나"는 클로저가 `v`를 upvalue로 직접
-- 캡처해서 알므로 별도 저장소가 불필요해짐(`tag-plan.md` "메커니즘" 절).
-- `tagNameMap`(이름 -> 그 이름을 걸고 있는 현재 홀더 집합)만 남음 — 이건
-- 여러 위치를 가로지르는 누적 상태라 여전히 필요.
--
-- 아래 TagRetract/TagProcess는 실제 base 코드의 함수 분리 방식(하나의
-- process가 반환하는 클로저)과는 다르게 두 개의 독립 함수로 남아있음 —
-- 이건 원래 스파이크 작성자가 검증 편의를 위해 고른 형태고, 이 세션의
-- 재작성 범위(B/C만)에 포함되지 않아 그대로 유지함. 참조 카운트 로직
-- 자체(홀더 집합 갱신 + `Contains` 힌트로 실제 RemoveTag만 skip)는
-- 실제 base 설계와 동등함.
local tagNameMap = makeRegistry() -- (inst,name) -> {[Tag]=true}
local removeLog = {}
local addLog = {}
local function names(tagList)
local out = {}
for _, n in tagList do
out[n] = true
end
return out
end
local function makeTag(...)
return { __names = names({ ... }) }
end
local function tagNames(tag)
local out = {}
for n in tag.__names do
table.insert(out, n)
end
return out
end
local function tagContains(tag, name)
return tag.__names[name] == true
end
-- kTagMap 대체: 이 스파이크 안에서만 쓰는 "이 위치가 직전에 걸었던 Tag"
-- 로컬 기억 — 실제 base에선 process가 반환하는 클로저의 upvalue가 이
-- 역할을 함(위 주석 참고). 여기선 TagRetract/TagProcess가 분리돼 있어
-- 호출부가 직접 넘겨줌.
local function TagRetract(inst, k, oldv, newv)
if not oldv then
return
end
local newvIsTag = newv ~= nil and newv.__names ~= nil
for _, name in tagNames(oldv) do
local holders = tagNameMap:get(inst, name) or {}
holders[oldv] = nil
tagNameMap:set(inst, name, holders)
if next(holders) == nil and not (newvIsTag and tagContains(newv, name)) then
table.insert(removeLog, name)
end
end
end
local function TagProcess(inst, k, v)
for _, name in tagNames(v) do
local holders = tagNameMap:get(inst, name) or {}
if next(holders) == nil then
table.insert(addLog, name)
end
holders[v] = true
tagNameMap:set(inst, name, holders)
end
end
-- Frame { Tag("a"), Tag("a", "b") } — 두 위치(k=1, k=2)가 "a"를 겹쳐 가짐
local tagPos1 = makeTag("a")
local tagPos2 = makeTag("a", "b")
TagRetract("Frame1", 1, nil, tagPos1)
TagProcess("Frame1", 1, tagPos1)
TagRetract("Frame1", 2, nil, tagPos2)
TagProcess("Frame1", 2, tagPos2)
-- Frame{Tag("a"), Tag("a","b")} 마운트 직후: "a"는 두 위치가 겹쳐 가지므로
-- AddTag("a")는 첫 홀더가 생길 때 한 번만 실제로 불려야 함(로그 = {a, b})
allPass = report("초기 마운트: AddTag 로그", #addLog == 2 and addLog[1] == "a" and addLog[2] == "b", "{a,b}", table.concat(addLog, ",")) and allPass
-- 위치1이 "a"를 잃음(Tag()로 교체, 빈 값) — 위치2가 아직 "a"를 쥐고 있으므로
-- 실제 RemoveTag("a")는 안 불려야 함
local emptyTag = makeTag()
TagRetract("Frame1", 1, tagPos1, emptyTag)
TagProcess("Frame1", 1, emptyTag)
allPass = report("위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림", #removeLog == 0, "0", #removeLog) and allPass
-- 위치2도 "a"를 잃음 — 이제 진짜로 RemoveTag("a")가 불려야 함(b는 그대로 유지)
local tagPos2b = makeTag("b")
TagRetract("Frame1", 2, tagPos2, tagPos2b)
TagProcess("Frame1", 2, tagPos2b)
allPass = report("마지막 홀더도 a를 잃으면 RemoveTag(a) 실제로 불림", #removeLog == 1 and removeLog[1] == "a", "{a}", table.concat(removeLog, ",")) and allPass
end
-- ===== B. Attribute 이름 소유권 — Dispatch 인덱스 점유 체크로 판정 =====
print()
print("=== B. Attribute — Dispatch.process 점유 체크가 이름 소유권 충돌을 잡는가 ===")
do
-- Dispatch.process/retractFrom을 아주 얇게 흉내냄 — (inst,key) 자리별로
-- 인덱스별 점유자(occupant)를 기록. 실제 Dispatch는 핸들러 매치/재귀도
-- 하지만 여기선 "인덱스 1이 이미 점유돼 있으면 error"라는 핵심 계약만
-- 재현하면 충분(attribute-plan.md "메커니즘" 절이 의존하는 게 정확히 이것).
local occupant = makeRegistry() -- (inst,key) -> claimant(임의의 identity)
local function DispatchProcess(inst, key, claimant, index)
assert(index == 1, "이 스파이크는 인덱스 1 직접 위임 케이스만 다룸")
local current = occupant:get(inst, key)
if current ~= nil and current ~= claimant then
error(string.format('attribute key "%s"는 이미 다른 claimant가 점유 중', tostring(key.__name)))
end
occupant:set(inst, key, claimant)
end
local function DispatchRetractFrom(inst, key, claimant, index)
assert(index == 1)
if occupant:get(inst, key) == claimant then
occupant:set(inst, key, nil)
end
end
-- AttributeKey(name) 이름별 weak 캐시 흉내 — 실제로는 GC weak지만 여기선
-- 순수 로직만 보므로 strong 캐시로 충분(동등성 보장이 핵심)
local keyCache = {}
local function AttributeKey(name)
local k = keyCache[name]
if not k then
k = { __name = name }
keyCache[name] = k
end
return k
end
-- 직접 리터럴 쓰기: [AttributeKey "Enabled"] = true, claimant는 이 자리
-- 자체(Modifier 필드 identity 대신 편의상 문자열로 대체)
local directClaimant = "directWrite"
DispatchProcess("Frame1", AttributeKey("Enabled"), directClaimant, 1)
-- 그룹이 rawNew 대신 공개 캐시로 같은 이름을 잡으려 하면 -> 점유 체크가 error
local groupClaimant = "group1"
local ok1 = pcall(function()
DispatchProcess("Frame1", AttributeKey("Enabled"), groupClaimant, 1)
end)
allPass = report("직접 쓰기가 점유한 이름을 그룹이 잡으려 하면 -> error", ok1 == false, false, ok1) and allPass
-- 그룹 자신의 재귀 diff 사이클 — AttributeGroupHandler.process/retractor 흉내.
-- process: 새 이름 집합 전부 인덱스 1로 위임. 반환 클로저: 자기가 등록한
-- 이름 전부 retractFrom(공개 캐시라 매번 같은 키 객체 재사용, 별도
-- 레지스트리 불필요 — attribute-plan.md "메커니즘" 절 그대로).
local function AttributeGroupProcess(inst, groupClaimant, names)
local registered = {}
for _, name in names do
DispatchProcess(inst, AttributeKey(name), groupClaimant, 1)
table.insert(registered, name)
end
return function()
for _, name in registered do
DispatchRetractFrom(inst, AttributeKey(name), groupClaimant, 1)
end
end
end
local retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Health", "Mana" })
local firstHealthKey = AttributeKey("Health") -- 캐시된 같은 객체인지 재확인용
-- 2번째 사이클: 옛 클로저가 먼저 걷어내고(하강 diff 이전 모델의 관용구대로
-- retract-then-process), 새 process가 같은 이름들을 다시 등록 — 캐시된
-- 같은 키 객체를 재사용해야 자기 자신과 충돌하지 않음
retractGroup()
retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Health", "Mana" })
local ok2 = AttributeKey("Health") == firstHealthKey
allPass = report("이름 캐시는 사이클 간 같은 키 객체 재사용(공개 AttributeKey 캐시)", ok2, true, ok2) and allPass
-- 같은 그룹이 "Health"를 잃었다가(2번째 사이클에서 Mana만 남김) 다시
-- 포함하는 경우 -> 자기 자신과 충돌하면 안 됨(자기가 걷어낸 자리를
-- 자기가 다시 잡는 것이므로) — 0-Z 이전, 현재 모델에서 정상 통과해야 함
retractGroup()
retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Mana" }) -- Health를 놓음
local ok3 = pcall(function()
retractGroup()
retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Health", "Mana" }) -- Health를 다시 포함
end)
allPass = report("같은 그룹이 이름을 놓았다 다시 포함해도 자기 자신과 안 충돌", ok3 == true, true, ok3) and allPass
-- 음성 대조군: 두 개의 서로 다른 그룹이 같은 이름을 동시에 관리하려 하면
-- (retractFrom 없이 process만 먼저 부르는 순서로) 반드시 점유 error가
-- 나야 함 — 이게 "체크포인트/owners 레지스트리 없이 점유 체크 하나로
-- 충돌을 잡는다"는 이 설계의 핵심 주장 그 자체
local groupBClaimant = "groupB"
local ok4 = pcall(function()
DispatchProcess("Frame2", AttributeKey("Mana"), groupBClaimant, 1) -- groupA가 아직 Mana를 쥔 채
end)
allPass = report("[핵심] 그룹A가 점유 중인 이름을 그룹B가 잡으려 하면 -> error(점유 체크가 충돌을 잡음)", ok4 == false, false, ok4) and allPass
-- 음성 대조군 2: 점유 체크를 흉내내지 않고(=폐기된 옛 모델처럼 아무
-- 체크 없이 그냥 덮어쓰면) 위 케이스가 조용히 통과해버림을 직접 보여줌 —
-- "이 스파이크가 실제로 뭔가를 검증하고 있다"는 반증
do
local naiveOccupant = {}
local function naiveProcess(key, claimant)
naiveOccupant[key] = claimant -- 점유 체크 없이 무조건 덮어씀(구 last-write-wins)
end
local key = "Mana"
naiveProcess(key, "groupA")
naiveProcess(key, "groupB") -- 아무 에러도 안 남 — 이게 바로 이 문서가 고쳤다고 주장하는 버그
allPass = report("(음성 대조군) 점유 체크 없는 naive 모델은 같은 상황에서 조용히 통과함(버그 재현)", naiveOccupant[key] == "groupB", "groupB", naiveOccupant[key]) and allPass
end
end
-- ===== C. Slot elementOwner — claimOwner(nested, 엄격) / claimOwnerAt(top-level) =====
print()
print("=== C. Slot elementOwner — claimOwner(엄격) vs claimOwnerAt(spurious 재발행 허용) ===")
do
local elementOwner = makeRegistry() -- element -> {OWNER=ownerKey, OWNER_POS=k}
local OWNER = "__owner"
local OWNER_POS = "__ownerPos"
-- nested(rawAdd) 전용 — 엄격: 이미 누가 갖고 있으면 같은 owner여도 error
local function claimOwner(element, ownerKey)
if elementOwner:get(element, OWNER) ~= nil then
error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지")
end
elementOwner:set(element, OWNER, ownerKey)
end
-- top-level(SlotHandler) 전용 — 정확히 같은 (inst,k) 자리의 spurious
-- 재발행만 false(no-op), 그 외 중복은 전부 error. 반환값 = 새로 클레임했는가.
local function claimOwnerAt(element, inst, k)
local current = elementOwner:get(element, OWNER)
if current == inst and elementOwner:get(element, OWNER_POS) == k then
return false -- 정확히 이 자리가 이미 들고 있음 — 재확인만
end
if current ~= nil then
error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
end
elementOwner:set(element, OWNER, inst)
elementOwner:set(element, OWNER_POS, k)
return true
end
local function releaseOwner(element, ownerKey)
local current = elementOwner:get(element, OWNER)
if current ~= ownerKey then
error("releaseOwner: 이 element는 이 ownerKey가 소유하고 있지 않음 — 호출측 소유권 추적이 깨졌음")
end
elementOwner:set(element, OWNER, nil)
elementOwner:set(element, OWNER_POS, nil)
end
-- --- C-1. nested claimOwner: 같은 owner 재클레임도 error(Slot{a,a} 방지) ---
do
local a = { __name = "elementA" }
local outerSlot = { __name = "outerSlot" }
claimOwner(a, outerSlot) -- rawAdd(outerSlot, a) — 최초 클레임, 정상
local ok1 = pcall(function()
claimOwner(a, outerSlot) -- 같은 outerSlot이 같은 a를 또 rawAdd(Slot{a,a} 시뮬레이션)
end)
allPass = report("C-1: nested Slot{a,a} — 같은 owner 재클레임도 -> error", ok1 == false, false, ok1) and allPass
-- 음성 대조군: 옛 3분기(같은 owner면 조용히 false)였다면 위 케이스가
-- 통과해버려 outerSlot._elements = {a, a}가 조용히 만들어짐
local function oldClaimOwner3way(element, ownerKey, registry)
local current = registry[element]
if current == ownerKey then
return false -- 옛 설계: no-op 취급, error 없음
end
if current ~= nil then
error("다른 곳에 마운트됨")
end
registry[element] = ownerKey
return true
end
local oldRegistry = {}
local claimed1 = oldClaimOwner3way(a, outerSlot, oldRegistry)
local claimed2 = oldClaimOwner3way(a, outerSlot, oldRegistry) -- rawAdd가 반환값을 안 보므로 그냥 통과
allPass = report("(음성 대조군) 옛 3분기 claimOwner였다면 Slot{a,a}가 에러 없이 통과함(claimed2=false, no error)", claimed1 == true and claimed2 == false, "true,false", tostring(claimed1) .. "," .. tostring(claimed2)) and allPass
releaseOwner(a, outerSlot)
end
-- --- C-2. 서로 다른 Slot에 같은 element -> error ---
do
local b = { __name = "elementB" }
local slot1 = { __name = "slot1" }
local slot2 = { __name = "slot2" }
claimOwner(b, slot1)
local ok2 = pcall(function()
claimOwner(b, slot2)
end)
allPass = report("C-2: 서로 다른 nested Slot에 같은 element -> error", ok2 == false, false, ok2) and allPass
releaseOwner(b, slot1)
end
-- --- C-3. top-level claimOwnerAt: 같은 (inst,k) 자리에서 같은 slot 재발행은 no-op ---
do
local slotA = { __name = "slotA" }
local frame1 = { __name = "Frame1" }
local claimed1 = claimOwnerAt(slotA, frame1, 1) -- 최초 마운트
allPass = report("C-3a: 최초 클레임은 true(실제로 붙음)", claimed1 == true, true, claimed1) and allPass
local claimed2 = claimOwnerAt(slotA, frame1, 1) -- 같은 (inst,k)에서 store 재발행
allPass = report("C-3b: 같은 (inst,k) 재발행은 false(no-op, 재파괴/재생성 없음)", claimed2 == false, false, claimed2) and allPass
releaseOwner(slotA, frame1)
end
-- --- C-4. top-level Frame{slot, slot}: 같은 inst, 다른 k -> error ---
do
local slotB = { __name = "slotB" }
local frame2 = { __name = "Frame2" }
claimOwnerAt(slotB, frame2, 1) -- k=1에 마운트
local ok3 = pcall(function()
claimOwnerAt(slotB, frame2, 2) -- 같은 inst, 다른 위치(k=2)에 같은 slotB
end)
allPass = report("C-4: [핵심] top-level Frame{slot,slot}(같은 inst, 다른 k) -> error", ok3 == false, false, ok3) and allPass
-- 음성 대조군: owner만 보고 위치(k)를 안 보는 옛 claimOwner였다면
-- current==inst라 조용히 false를 반환해 버림 -> 이후 파괴적 클로저가
-- 두 자리 모두에서 반환되어 이중 파괴로 이어지는 게 실제 발견된 버그
local function oldClaimOwnerNoPos(element, inst, registry)
local current = registry[element]
if current == inst then
return false -- 위치를 안 봐서 k=2도 그냥 no-op 취급 — 버그
end
if current ~= nil then
error("다른 곳에 마운트됨")
end
registry[element] = inst
return true
end
local oldRegistry = {}
local oc1 = oldClaimOwnerNoPos(slotB, frame2, oldRegistry)
local oc2 = oldClaimOwnerNoPos(slotB, frame2, oldRegistry) -- k=2에서도 에러 없이 false — 버그 재현
allPass = report("(음성 대조군) 위치를 안 보는 옛 claimOwner였다면 Frame{slot,slot}이 에러 없이 통과함", oc1 == true and oc2 == false, "true,false", tostring(oc1) .. "," .. tostring(oc2)) and allPass
releaseOwner(slotB, frame2)
end
-- --- C-5. release 후 재클레임은 정상 통과 ---
do
local slotC = { __name = "slotC" }
local frame3 = { __name = "Frame3" }
local frame4 = { __name = "Frame4" }
claimOwnerAt(slotC, frame3, 1)
releaseOwner(slotC, frame3)
local claimed = claimOwnerAt(slotC, frame4, 1) -- 해제 후 완전히 다른 곳에 재마운트
allPass = report("C-5: release 이후엔 다른 owner가 정상 클레임 가능", claimed == true, true, claimed) and allPass
releaseOwner(slotC, frame4)
end
-- --- C-6. 불일치 release -> error ---
do
local slotD = { __name = "slotD" }
local frame5 = { __name = "Frame5" }
local frame6 = { __name = "Frame6" }
claimOwnerAt(slotD, frame5, 1)
local ok4 = pcall(function()
releaseOwner(slotD, frame6) -- frame5가 쥐고 있는데 frame6이 release 시도
end)
allPass = report("C-6: 불일치 release -> error(호출측 소유권 추적 붕괴 즉시 감지)", ok4 == false, false, ok4) and allPass
releaseOwner(slotD, frame5) -- 정리
end
end
print()
print(allPass and "=== 전체 PASS ===" or "=== 하나 이상 FAIL — 위 로그에서 어느 케이스인지 확인할 것 ===")
--[[
확인 포인트:
1. A: "위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림" — 이게 FAIL이면
웹 className류 합집합 시맨틱이 실제로 안 되는 것이므로 tag-plan.md의
`tagNameMap` 알고리즘 자체를 재검토해야 함(최우선 보고).
2. B: "[핵심] 그룹A가 점유 중인 이름을 그룹B가 잡으려 하면 -> error" 케이스가
FAIL이면, `Dispatch.process`의 인덱스 점유 체크만으로 그룹↔그룹 이름
충돌을 잡는다는 attribute-plan.md의 현재 모델 자체가 성립 안 하는 것 —
단, 이건 재디스패치 "하강 diff" 모델 도입 후에는 어차피 다시 뚫리는
케이스로 이미 알려져 있음(`.claude/question.md` 0-Z) — 그 결정이 나면
이 섹션 전체를 그 결정에 맞춰 다시 써야 함.
3. C: C-1(nested 재클레임 error)/C-4(top-level 다른 위치 error)가 이
스파이크의 핵심 — 둘 다 "옛 3분기/위치 미검사 설계였다면 조용히
통과했을 것"을 음성 대조군으로 같이 보여줌. 이 중 하나라도 FAIL이면
Slot의 "재귀 재emit마다 서브트리가 파괴됐다 재생성되는" 또는 "이중
파괴" 버그로 직결되므로 최우선 보고.
]]