decide(slot): break the kSlotMap/slotOwner mutual strong-reference cycle
Two separate Relates strongly holding each other's key as their value (inst->slot in one, slot->inst in the other) is a mutual-reachability cycle that ordinary weak-key GC can't resolve on its own -- the case Lua 5.2 ephemerons exist for. Weaken both to pure lookups and make bindLifetime/unbindLifetime (already used for Slot's own observers) the single GC anchor for the slot object itself, which attachSlot/ destroySlotTree were missing. Audited every other Relate() in base/ for the same shape (an object other than inst used as the outer key) -- slotOwner was the only one; everything else either has no back-reference to inst or is the safe single-table self-reference shape bindLifetime already relies on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2b7e90cd92
commit
95fdfc4b12
3 changed files with 104 additions and 11 deletions
|
|
@ -213,31 +213,51 @@ retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음** —
|
|||
추적** — 이게 이미 확정된 "한 element가 어디에도 중복 마운트 안 됨"
|
||||
전역 불변식(위 "요소 타입 제약" 절)을 Slot 컨테이너 자신에도 그대로
|
||||
적용하는 것이라 더 정확함(위치 비교로는 "이 Slot이 동시에 다른 위치에도
|
||||
마운트돼 있는가"를 못 잡음):
|
||||
마운트돼 있는가"를 못 잡음).
|
||||
|
||||
**[GC 주의, 2026-08-12 열세 번째 세션] `kSlotMap`/`slotOwner` 둘 다
|
||||
Slot을 `SetStrong`으로 저장하면 안 됨 — `kSlotMap[inst][k]=slot`(강)과
|
||||
`slotOwner[slot]=inst`(강)가 동시에 있으면 **서로 다른 두 `Relate`가
|
||||
맞물려 서로를 살려주는 순환**이 생김(`inst`가 살아있어야 `slotOwner`가
|
||||
`slot`을 붙잡고, `slot`이 살아있어야 `kSlotMap`이 `inst`를 붙잡는 식 —
|
||||
둘 다 서로에게만 기대면 어느 쪽 reachability도 외부에서 못 끊음). 이건
|
||||
`bindLifetime`이 이미 쓰는 "한 `Relate` 안에서 값이 자기 키를 다시
|
||||
참조하는" 패턴(`Dispatch.setLength`의 `observer`가 클로저로 `inst`를
|
||||
캡처하는 것, `Ref.Value=inst` 등)과는 **다른, 더 위험한 모양**이다 —
|
||||
단일 테이블 자기참조는 그 테이블의 키(`inst`)가 이 테이블 *바깥에서부터*
|
||||
독립적으로 reachable한지만 판별하면 끝나지만, 두 개의 별도 weak 테이블이
|
||||
서로의 키를 상대방 값으로 제공하는 상호 순환은 그 판별 자체가 서로에게
|
||||
의존해버려 일반적인 weak-table GC로 한 번에 안 풀릴 위험이 있음(Lua
|
||||
5.2+ ephemeron이 풀려고 만들어진 바로 그 사례) — Luau가 실제로 이걸
|
||||
올바르게 처리하는지 검증된 바 없으니 설계로 아예 피함. **해법: 실제
|
||||
GC 앵커는 `bindLifetime`/`unbindLifetime`(이미 결정돼 있었는데
|
||||
`attachSlot`/`destroySlotTree`에 적용이 안 돼 있던 부분, 이번에 추가)
|
||||
하나로만 두고, `kSlotMap`/`slotOwner`는 전부 `SetWeak`(순수 조회용,
|
||||
아무것도 안 붙잡음)로 낮춤**:
|
||||
|
||||
```lua
|
||||
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot(retract가 뭘 지울지 알아야 함)
|
||||
local slotOwner = Relate() -- Slot 자신이 weak 키 — {[slot] = 지금 바인딩된 inst}
|
||||
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용
|
||||
local slotOwner = Relate() -- Slot 자신이 weak 키 — {[slot] = 지금 바인딩된 inst} — weak, 조회 전용
|
||||
|
||||
function SlotHandler.process(inst, k, slotValue)
|
||||
local owner = slotOwner:GetStrong(slotValue)
|
||||
local owner = slotOwner:GetWeak(slotValue)
|
||||
if owner == inst then
|
||||
return -- 이미 이 inst에 바인딩된 채 — 단순 emit 전파, no-op
|
||||
end
|
||||
if owner ~= nil then
|
||||
error("이 Slot은 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
|
||||
end
|
||||
attachSlot(slotValue, inst, inst, k)
|
||||
slotOwner:SetStrong(slotValue, inst)
|
||||
kSlotMap:SetStrong(inst, k, slotValue)
|
||||
attachSlot(slotValue, inst, inst, k) -- 내부에서 bindLifetime(inst, slotValue) 호출(아래)
|
||||
slotOwner:SetWeak(slotValue, inst)
|
||||
kSlotMap:SetWeak(inst, k, slotValue)
|
||||
end
|
||||
|
||||
function SlotHandler.retract(inst, k, v)
|
||||
local old = kSlotMap:GetStrong(inst, k)
|
||||
local old = kSlotMap:GetWeak(inst, k)
|
||||
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음
|
||||
destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로
|
||||
slotOwner:SetStrong(old, nil) -- 관계 해제 — old를 나중에 다른 곳에 다시 마운트해도 됨
|
||||
kSlotMap:SetStrong(inst, k, nil)
|
||||
destroySlotTree(old) -- 내부에서 unbindLifetime(inst, old) 호출(아래), 폐기·옮기지 않음
|
||||
slotOwner:SetWeak(old, nil)
|
||||
kSlotMap:SetWeak(inst, k, nil)
|
||||
end
|
||||
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 owner==inst로 no-op
|
||||
end
|
||||
|
|
@ -1082,6 +1102,10 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
|
|||
local function attachSlot(slot, physicalTarget, ownerKey, position)
|
||||
slot._mounted = true
|
||||
slot._mountedInst = physicalTarget
|
||||
bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] Slot 자신의
|
||||
-- GC 앵커 — 이게 빠져있으면 아무도 slot을 강하게
|
||||
-- 안 붙잡아 조기 GC될 수 있음(위 "Slot과 Store
|
||||
-- 바인드의 관계" 절 GC 주의 참고)
|
||||
|
||||
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
|
||||
local offsetSource = Source(0)
|
||||
|
|
@ -1149,6 +1173,8 @@ local function destroySlotTree(slot)
|
|||
unbindLifetime(slot._mountedInst, observer)
|
||||
end
|
||||
end
|
||||
unbindLifetime(slot._mountedInst, slot) -- [2026-08-12 열세 번째 세션 추가]
|
||||
-- attachSlot의 bindLifetime과 짝
|
||||
end
|
||||
|
||||
function rawRemove(self, index)
|
||||
|
|
|
|||
48
.claude/session/2026-08-12-13-slot-gc-cycle-fix.md
Normal file
48
.claude/session/2026-08-12-13-slot-gc-cycle-fix.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# 2026-08-12 열세 번째 세션 — `Slot`의 두-`Relate` 상호 GC 순환 수정
|
||||
|
||||
## 배경
|
||||
|
||||
직전 세션(열두 번째, `slot→inst` 소유권 relate 신설)에서 사용자가
|
||||
자신이 방금 확인한 설계에 GC 위험을 지적: `slotOwner`(slot→inst)와
|
||||
`kSlotMap`(inst→slot)이 둘 다 `SetStrong`이면 서로가 서로를 살려주는
|
||||
순환이 생겨 GC가 안 됨. `attachSlot`에서 slot을 `gchold`(=`bindLifetime`)에
|
||||
넣는 부분이 필요한데 적용이 안 됐다고 지적, 다른 곳에도 같은 패턴이
|
||||
있는지 감사 요청, 마지막엔 slot을 `SetWeak`로 두라는 결론.
|
||||
|
||||
## 진단 — 위험도가 다른 두 패턴 구분
|
||||
|
||||
`bindLifetime`이 이미 광범위하게 쓰는 "값이 자기 키를 다시 참조"
|
||||
패턴(`Dispatch.setLength`의 `observer` 클로저가 `inst`를 캡처, `Ref.Value
|
||||
=inst`)은 **단일 테이블 자기참조** — 그 테이블의 키(`inst`)가 테이블
|
||||
바깥에서 독립적으로 reachable한지만 판별하면 되므로 표준 weak-key GC로
|
||||
안전하게 풀림(둘 다 외부 참조 없이 죽으면 함께 정리됨, 순환이 이
|
||||
판별을 방해 안 함).
|
||||
|
||||
반면 `kSlotMap`(inst 키, slot 값, 강)과 `slotOwner`(slot 키, inst 값,
|
||||
강)는 **서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 제공하는
|
||||
상호 순환** — `inst`의 reachability 판별이 `slot`의 reachability에
|
||||
의존하고, 그 반대도 마찬가지라 판별 자체가 서로에게 순환 의존함. 이건
|
||||
Lua 5.2+가 ephemeron을 도입해서 풀려던 바로 그 사례라 표준 weak-key
|
||||
GC(특히 ephemeron 미지원 구현)로 한 번에 안 풀릴 위험이 있음 — Luau의
|
||||
실제 동작이 검증된 바 없으니 설계로 아예 피함.
|
||||
|
||||
## 전체 corpus 감사
|
||||
|
||||
`grep -n "Relate()"`로 base/ 전체의 모든 `Relate` 인스턴스 나열 —
|
||||
`inst`가 아닌 다른 값을 바깥 키로 쓰는 건 이번에 새로 만든 `slotOwner`가
|
||||
유일했음. 나머지(`chains`/`owners`/`kTagMap`/`tagNameMap`/Ref의 `relate`
|
||||
등)는 전부 `inst`만 바깥 키로 쓰고, 안에 담기는 값(Tag/AttributeKey/
|
||||
Handler)이 `inst`로 되돌아가는 back-reference를 안 가짐(Tag는 순수
|
||||
데이터, AttributeKey도 `{Name=name}`뿐) — 두 테이블 상호순환 위험
|
||||
없음 확인. `Ref.Value=inst`는 back-reference가 있지만 단일 테이블 자기참조
|
||||
형태라 안전한 쪽으로 분류.
|
||||
|
||||
## 반영
|
||||
|
||||
- `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절 — `kSlotMap`/
|
||||
`slotOwner` 둘 다 `SetStrong`→`SetWeak`로 낮춤(순수 조회 전용).
|
||||
- `attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가(Slot 자신의
|
||||
GC 앵커 — 기존엔 이게 없어서 아무도 slot을 강하게 안 붙잡는 상태였음).
|
||||
- `destroySlotTree`에 짝인 `unbindLifetime(slot._mountedInst, slot)`
|
||||
추가(기존엔 자식 observer들의 unbindLifetime만 있고 slot 자신의
|
||||
해제가 빠져 있었음).
|
||||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -594,3 +594,22 @@ Claude가 지적했으나, 사용자가 "덮여 쓰여지는 즉시 retract 실
|
|||
Tag/Ref/Slot/Attribute가 이번 대화 내내 의존해온 그 메커니즘)는 서로
|
||||
다른 용도라 하나로 안 합쳐짐 — old value를 각 핸들러가 자기 `Relate`로
|
||||
저장한다는 원래 결정과는 무관, `retractUnder`는 old를 옮긴 적이 없음.
|
||||
|
||||
**2026-08-12 열세 번째 세션 — `Slot`의 두-`Relate` 상호 GC 순환 수정**
|
||||
(`session/2026-08-12-13-slot-gc-cycle-fix.md`)
|
||||
사용자가 직전 세션의 `slotOwner`(slot→inst)/`kSlotMap`(inst→slot)이 둘 다
|
||||
`SetStrong`이면 서로가 서로를 살려주는 순환이 생겨 GC가 안 된다고 지적 —
|
||||
`bindLifetime`이 이미 쓰는 "값이 자기 키를 다시 참조"하는 단일 테이블
|
||||
자기참조(`Dispatch.setLength`의 `observer` 클로저가 `inst` 캡처,
|
||||
`Ref.Value=inst`)는 그 키가 테이블 바깥에서 독립 reachable한지만
|
||||
판별하면 돼서 안전하지만, **서로 다른 두 `Relate`가 서로의 키를 상대방
|
||||
값으로 제공하는 상호 순환**은 판별 자체가 서로에게 의존해버려 Lua
|
||||
5.2+ ephemeron이 풀려던 바로 그 사례라는 걸 Claude가 재확인. `grep`으로
|
||||
base/ 전체 `Relate()` 인스턴스를 감사한 결과 `inst`가 아닌 다른 값을
|
||||
바깥 키로 쓰는 건 `slotOwner`가 유일했음(나머지는 담긴 값이 `inst`로
|
||||
되돌아가는 back-reference가 없거나, 있어도 단일 테이블이라 안전).
|
||||
`kSlotMap`/`slotOwner` 둘 다 `SetWeak`로 낮추고, 실제 GC 앵커는
|
||||
`bindLifetime`/`unbindLifetime` 하나로 통일 — `attachSlot`에
|
||||
`bindLifetime(physicalTarget, slot)`, `destroySlotTree`에 짝인
|
||||
`unbindLifetime(slot._mountedInst, slot)` 추가(기존엔 자식 observer들의
|
||||
unbindLifetime만 있고 slot 자신의 앵커/해제가 빠져 있었음).
|
||||
|
|
|
|||
Loading…
Reference in a new issue