qa: 반영 후 감사 6라운드 — 실제 크래시 3건 포함 18건 수정
커밋 9b7f847(Detach/KeyGone/Owned/attachSlot 분해) 반영 후 각도를 바꿔가며
quad-doc-auditor를 6라운드 돌린 결과. 라운드별 확실 발견 4/6/2/3/3/0으로
6라운드에서 새 발견 0건 — 수렴 확인 후 종료. 경위 전량은
qa-request/pre-implementation-qa-round4-followup.md의 I절이 소스.
트레이싱 라운드(4~5)가 잡은 실제 크래시 — 셋 다 Detach가 신설한 경로가
기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것:
- I-1 (치명): rawDetach가 소유권을 유지하는데 재마운트는 rawAdd →
claimOwner를 거치고, claimOwner는 같은 owner의 재클레임도 무조건 error다
(2026-08-13 감사가 Slot{a,a}를 막으려고 넣은 것). 문서가 권장하는
"prev를 그대로 반환하면 재마운트" 패턴이 그대로 죽었음. fromDetached
플래그로 그 경로만 좁게 예외 처리.
- I-2: 재마운트된 자식 Slot이 activateList를 두 번 실행해 구독이 이중으로
생기고 mounted/keyIndex 클로저 상태가 통째로 새로 만들어짐 → 멱등 가드.
가드만으로는 :List 구독이 옛 physicalTarget에 앵커된 채 남아 포탈
재마운트 후 조용히 멈추므로, _listObserver 핸들 보관 + 재앵커까지 처리.
- I-3: _detachCleanup이 releaseOwner를 안 불러 Owned=false 요소가 죽은
Slot을 owner로 달고 남음 → 두 분기 공통으로 호출.
- I-6: 위 수정의 회귀 트레이싱 — destroySlotTree에 _listObserver 해제 누락,
claimOwner의 옛 논증 두 문단이 fromDetached와 정면 모순, 소유권 예시
코드가 C-4와 모순.
- I-7: 사용자가 별도 상의해 가져온 두 건 — _detachCleanup 설치를
mountSlotTree → activateList로 이관(:List 없는 Slot마다 no-op Effect를
트리 크기만큼 심고 있었음), activateList의 inst → physicalTarget 리네이밍.
이관 근거가 멱등 가드 이전 동작을 전제하고 있어 가드 분기의 재앵커까지
같이 반영. 이로써 _listObserver/_detachCleanup이 같은 범주로 통일됨.
I-4(materializeSlotTree 중 예외 시 Blocker 잔류)는 사용자 판단으로 pcall
없이 문서화만 — 아직 밟은 적 없는 경로이고 옛 단일 attachSlot에도 있었을
구조적 갭.
문서 정합성 라운드(1~3)에서 나온 것: slot-plan의 "값 교체는 비파괴" 잔존,
분해 완료 후에도 남아 있던 "논의 대기 중" 배너, attachSlot의 flush 루프를
가리키던 문장 5곳, README 색인 행이 2026-08-19에서 멈춰 있던 것,
qa-round4 문항지/followup의 "회신 대기" 상태줄, todos의 용어 목록 이중 소스,
dispatch-core의 raw* 일반 계약에 rawDetach 누락.
luau-test: 스파이크 01이 "재작성 필요" 마커를 단 채 done/에 남아 있어
STATUS.md 자신의 "폴더가 곧 상태" 규칙을 어기고 있었음 → rewrite-required/로
이동하고 개수 정정. "만들어야 할 스파이크" 절 신설(아직 파일조차 없는 실측
항목이 어느 폴더로도 표현되지 않아 구조적으로 잊히던 자리).
doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
9b7f847014
commit
4622fbeec8
12 changed files with 508 additions and 89 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1592,8 +1592,8 @@ end
|
|||
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
|
||||
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:
|
||||
|
||||
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기
|
||||
자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그
|
||||
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree`가
|
||||
자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그
|
||||
owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작
|
||||
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고
|
||||
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
|
||||
|
|
@ -1611,7 +1611,7 @@ end
|
|||
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
|
||||
배치 중이라도 항상 최신값을 보게 됨.
|
||||
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는
|
||||
`attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을
|
||||
`materializeSlotTree`의 등록 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을
|
||||
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
|
||||
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
|
||||
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
|
||||
|
|
@ -1700,7 +1700,10 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
|
|||
- **각 연산에 적용하면**:
|
||||
- `rawAdd` — `Length:Set(newCount)`(→ 뒤 형제 offset 갱신 동기 완료) →
|
||||
`element.Parent = target`. 이미 이렇게 확정돼 있음(아래 문단).
|
||||
- `rawRemove`/`rawUnmount` — 파괴/언마운트 → `spliceArraysDown` →
|
||||
- `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach`
|
||||
경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만
|
||||
한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 →
|
||||
`spliceArraysDown` →
|
||||
`recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과
|
||||
어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직
|
||||
트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는
|
||||
|
|
|
|||
|
|
@ -297,11 +297,23 @@ local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak
|
|||
-- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고)
|
||||
local OWNER_POS = "__ownerPos" -- [2026-08-13 감사 신설] owner 안에서의 위치(top-level만 씀, 아래 참고)
|
||||
|
||||
-- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error
|
||||
local function claimOwner(element, ownerKey)
|
||||
if elementOwner:GetWeak(element, OWNER) ~= nil then
|
||||
-- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error.
|
||||
-- [2026-08-21 감사] 예외 하나: **detach 재마운트**. `rawDetach`가 소유권을
|
||||
-- 일부러 유지하므로(`slot._detached`가 계속 들고 있음), 다음 사이클에
|
||||
-- `prev`를 그대로 돌려주는 문서 권장 패턴이 여기서 무조건 죽고 있었다.
|
||||
-- top-level `claimOwnerAt`이 이미 "같은 (inst,k) 자리의 재발행은 통과"라는
|
||||
-- 같은 모양의 예외를 갖고 있어 대칭이 맞는다.
|
||||
local function claimOwner(element, ownerKey, fromDetached)
|
||||
local cur = elementOwner:GetWeak(element, OWNER)
|
||||
if cur ~= nil then
|
||||
-- 내가 계속 들고 있던 요소를 내가 다시 넣는 경우만 통과.
|
||||
-- `fromDetached` 없이 같은 owner라는 것만으로 통과시키면
|
||||
-- `Slot{a, a}`가 다시 조용히 새어나간다(2026-08-13 감사가 막은 것).
|
||||
if not (fromDetached and cur == ownerKey) then
|
||||
error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지")
|
||||
end
|
||||
return -- 이미 내 것이므로 SetWeak도 불필요
|
||||
end
|
||||
elementOwner:SetWeak(element, OWNER, ownerKey)
|
||||
end
|
||||
|
||||
|
|
@ -411,7 +423,20 @@ end
|
|||
이 소유권 논증 자체는 그대로 성립),
|
||||
`rawMove`/`rawSwap`은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가
|
||||
갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 **무조건
|
||||
error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으로
|
||||
error가 맞음**.
|
||||
|
||||
**⚠️ [정정, 2026-08-21] 이제 그 경로가 정확히 하나 생겼다 — `Detach`
|
||||
재마운트.** `rawDetach`는 일부러 `releaseOwner`를 안 부르므로(소유권 유지가
|
||||
`Detach`의 핵심), `settle`의 재마운트 분기는 `rawUnmount`를 거치지 않고
|
||||
곧바로 `rawAdd`를 부른다 — 위 문단이 "없다"고 단정한 바로 그 모양이다.
|
||||
그래서 `claimOwner`에 **`fromDetached` 플래그가 참일 때만** 통과하는 좁은
|
||||
예외를 뒀다(위 그 함수의 정의). **플래그 없이 "같은 owner면 통과"로
|
||||
완화하면 안 된다** — 이 절이 애초에 막으려던 `Slot { a, a }`가 다시
|
||||
새어나간다. 나머지 논증(top-level은 `claimOwnerAt`으로 구분)은 그대로
|
||||
유효하다. 경위는 `qa-request/pre-implementation-qa-round4-followup.md`의
|
||||
`I-1`.
|
||||
|
||||
반대로 top-level은 store 재발행마다 같은 Slot으로
|
||||
`process`가 다시 불리는 게 정상 경로라 그 케이스만 구분해줘야 하고,
|
||||
그러려면 owner 하나로는 부족해서 위치(`k`)까지 봐야 함.
|
||||
|
||||
|
|
@ -451,14 +476,17 @@ Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의
|
|||
봄:
|
||||
|
||||
```lua
|
||||
-- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리
|
||||
claimOwner(element, self) -- self = 담는 Slot. 이미 누가(같은 self 포함) 소유 중이면 여기서 error
|
||||
-- rawAdd(self, element, index, fromDetached?) 안, "이미 마운트" 에러 체크 자리
|
||||
claimOwner(element, self, fromDetached) -- self = 담는 Slot. 이미 누가(같은 self
|
||||
-- 포함) 소유 중이면 error — 단 detach
|
||||
-- 재마운트만 예외(위 그 함수)
|
||||
|
||||
-- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리
|
||||
releaseOwner(element, self)
|
||||
|
||||
-- destroySlotTree(slot) 안, 자식들을 파괴하기 직전(아래 "파괴" 절)
|
||||
releaseOwner(element, slot)
|
||||
-- [삭제됨, 2026-08-20 `C-4`] destroySlotTree에는 명시적 releaseOwner가 없다 —
|
||||
-- 2026-08-13 감사가 넣었던 걸 되돌렸다(자식이 어차피 죽으므로 불필요).
|
||||
-- 아래 "소유권 반납은 GC에 맡기면 안 됨" 절의 재정정이 소스.
|
||||
```
|
||||
|
||||
**[2026-08-13 감사] `claimOwner`는 반환값이 없음 — 성공 아니면 error다.**
|
||||
|
|
@ -470,6 +498,9 @@ releaseOwner(element, slot)
|
|||
바뀌었으나 둘 다 `releaseOwner`를 부르므로 논증 동일, `rawMove`/`rawSwap`은
|
||||
클레임 미접촉) 무조건 error가 맞음 —
|
||||
top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함.
|
||||
(**[정정, 2026-08-21]** "재클레임이 정당한 경우가 하나도 없다"는 전제는
|
||||
`Detach` 재마운트 하나가 예외로 생겼다 — 바로 위 ⚠️ 문단이 소스.
|
||||
`claimOwner`가 반환값 없이 error만 낸다는 이 항목의 결론 자체는 그대로다.)
|
||||
|
||||
**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawUnmount`/
|
||||
`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다(**[표현 정밀화,
|
||||
|
|
@ -1116,7 +1147,30 @@ end
|
|||
|
||||
-- Dispatch/Slot.luau의 process(inst,k,self,index)가 마운트 시점에 1회 호출
|
||||
-- (self._mounted=true/self._mountedInst=inst, self.Offset 세팅과 같은 자리)
|
||||
function activateList(self, inst)
|
||||
-- [리네이밍, 2026-08-21] 2번째 인자는 `inst`였으나 `physicalTarget`으로 통일 —
|
||||
-- 옆 함수들(materializeSlotTree/mountSlotTree/attachSlot)이 쓰는 이름과 같은
|
||||
-- 개념이고, 실제로 항상 물리 Instance다(bindLifetime의 첫 인자로 들어감).
|
||||
-- Slot일 수는 없다 — 그 Slot은 이미 1번째 인자 `self`다.
|
||||
function activateList(self, physicalTarget)
|
||||
-- [2026-08-21 감사] **멱등 가드 — 두 번째 호출은 즉시 반환.**
|
||||
-- `Detach`로 뗐다 돌아온 자식 Slot이 `:List`를 갖고 있으면
|
||||
-- `materializeSlotTree`가 `slot._listed`를 보고 여기를 다시 부른다.
|
||||
-- 가드가 없으면 `data:Observer(fn)` 구독이 하나 더 생기고
|
||||
-- `mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져**
|
||||
-- 옛 상태를 잃은 채 reconcile이 두 벌 돈다(기존 요소를 전부 새 것으로
|
||||
-- 오인해 다시 그림). `_crudUsed`/`_listed`와 같은 결의 플래그.
|
||||
if self._listActivated then
|
||||
-- 재마운트(포탈 포함) — 클로저 상태(mounted/userdata/keyIndex)는
|
||||
-- 그대로 두고 **GC 앵커만 새 target으로 옮긴다.** 이걸 안 하면
|
||||
-- 자원이 옛 physicalTarget에 매달린 채로 남아, 그게 죽는 순간
|
||||
-- 살아있는 이 Slot의 `:List`가 조용히 반응을 멈추고(`_listObserver`)
|
||||
-- 살아있는 detached 요소가 파괴된다(`_detachCleanup`).
|
||||
bindLifetime(physicalTarget, self._listObserver)
|
||||
bindLifetime(physicalTarget, self._detachCleanup)
|
||||
return
|
||||
end
|
||||
self._listActivated = true
|
||||
|
||||
local keyFn, updateFn = self._keyFn, self._updateFn
|
||||
local offset = self.Offset
|
||||
local mounted, userdata, keyIndex = {}, {}, {}
|
||||
|
|
@ -1135,13 +1189,15 @@ function activateList(self, inst)
|
|||
self._detached[key], mounted[key] = wasMounted, nil
|
||||
end
|
||||
elseif result == nil then
|
||||
-- "지워라". Owned=false면 파괴 대신 언마운트만(위 "Owned" 절).
|
||||
-- "지워라". Owned=false면 파괴 대신 언마운트만(아래 "Owned" 절).
|
||||
if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
|
||||
mounted[key], self._detached[key] = nil, nil
|
||||
elseif result == prev then
|
||||
if wasDetached ~= nil then
|
||||
self._detached[key] = nil -- **재마운트** — detach된 걸 되살림
|
||||
rawAdd(self, result, pos)
|
||||
-- 4번째 인자 = fromDetached. 소유권을 놓은 적이 없으므로
|
||||
-- `claimOwner`가 재클레임을 허용해야 한다(위 그 함수 주석).
|
||||
rawAdd(self, result, pos, true)
|
||||
mounted[key] = result
|
||||
elseif keyIndex[key] ~= pos then
|
||||
rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
|
||||
|
|
@ -1211,13 +1267,43 @@ function activateList(self, inst)
|
|||
keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다**
|
||||
end
|
||||
|
||||
-- [이관, 2026-08-21] `_detached` 정리용 Effect는 원래 `mountSlotTree`가
|
||||
-- 모든 Slot마다 설치했으나 **여기로 옮겼다**(사용자 판단). `_detached`를
|
||||
-- 채우는 유일한 지점이 아래 `settle`의 `rawDetach`이므로, `:List`가 없는
|
||||
-- Slot의 `_detached`는 정의상 영원히 빈 테이블이다 — 중첩 트리 크기만큼
|
||||
-- no-op Effect와 `bindLifetime` 엔트리를 심고 있었다. 이제
|
||||
-- `_listObserver`와 **완전히 같은 취급**을 받는다(생성은 여기 1회,
|
||||
-- 언마운트 시 앵커만 해제하고 핸들 보존, 재마운트 시 위 가드가 재앵커,
|
||||
-- 파괴 시 해제+`nil`) — "`activateList`가 소유하고 물리 target에
|
||||
-- 앵커되는 자원"이라는 한 범주.
|
||||
-- Effect가 유일한 도구인 이유: `bindLifetime`은 "실행해도 되는가"만
|
||||
-- 게이팅할 뿐 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`).
|
||||
self._detachCleanup = Effect(function()
|
||||
return function()
|
||||
for key, element in pairs(self._detached) do
|
||||
-- releaseOwner를 **먼저, 두 분기 공통으로**. 빠뜨리면
|
||||
-- `_owned == false` 요소가 죽은 Slot을 owner로 달고 남아,
|
||||
-- 사용자가 그 값을 다른 Slot에 넣을 때 GC 타이밍에 따라
|
||||
-- "이미 마운트돼 있음" error가 난다 — 위 "소유권 반납은
|
||||
-- GC에 맡기면 안 됨" 절이 경계하는 실패 모드.
|
||||
releaseOwner(element, self)
|
||||
if self._owned ~= false then
|
||||
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
|
||||
end
|
||||
self._detached[key] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
bindLifetime(physicalTarget, self._detachCleanup)
|
||||
|
||||
local data = self._listData
|
||||
if isState(data) then
|
||||
local observer = data:Observer(function() reconcile(data:Get()) end)
|
||||
self._listObserver = observer -- [2026-08-21] 재마운트 시 앵커를 옮기려면 보관 필요
|
||||
-- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute
|
||||
-- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) —
|
||||
-- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속
|
||||
bindLifetime(inst, observer)
|
||||
bindLifetime(physicalTarget, observer)
|
||||
else
|
||||
reconcile(data)
|
||||
end
|
||||
|
|
@ -1312,7 +1398,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운
|
|||
|
||||
| updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 |
|
||||
|---|---|---|
|
||||
| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state<Frame>` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(위 "`Owned` 옵션" 절) |
|
||||
| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state<Frame>` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(아래 "`Owned` 옵션" 절) |
|
||||
| `nil` / `None` | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 |
|
||||
| `Detach` | **언마운트만 + `slot._detached`가 계속 보유** | 아래. 이미 detach 상태면 **nop** |
|
||||
| `prev`를 그대로 반환 | 마운트 중이면 위치만 이동, **detach 중이면 재마운트** | 아래 |
|
||||
|
|
@ -1415,7 +1501,10 @@ updateFn(item: T | KeyGone, index, offset, prev, ud)
|
|||
`keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번
|
||||
사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된
|
||||
것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시
|
||||
나타나면 `prev`로 부활하고, (b) owner가 죽으면 아래 `Effect`가 정리한다.
|
||||
나타나면 `prev`로 부활하고, (b) owner가 죽으면 `activateList`가 설치한
|
||||
`_detachCleanup` Effect가 정리한다(**[이관, 2026-08-21]** 원래
|
||||
`mountSlotTree`에 있었으나, `_detached`를 채우는 건 `:List`뿐이라
|
||||
`:List` 없는 Slot마다 no-op Effect를 심고 있었다).
|
||||
- **`userdata`도 유저가 정한다** — `updateFn`이 `nil`을 반환해야 지워진다.
|
||||
키가 이미 사라졌으므로 다시 물어볼 기회가 없다는 뜻이지만, **owner가 죽으면
|
||||
전부 같이 사라지므로 영구 누수는 아니다**(사용자 확정: *"대신에 slot 의
|
||||
|
|
@ -1690,22 +1779,25 @@ nil/None 금지)는 그대로.
|
|||
|
||||
### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용
|
||||
|
||||
> **🔭 [2026-08-21] `attachSlot`의 책임 분해가 확장 논의 대기 중** — 아래
|
||||
> 의사코드가 지금 정본이고 그대로 유효하지만, 이 함수 하나가 지고 있는 책임이
|
||||
> 일곱 개(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치
|
||||
> 게이팅, 자식 배치, 재귀)라는 사용자 지적이 있었다 — *"attachSlot 의 기능이
|
||||
> 너무 다양해진게 문제같음"*. 특히 **"부모에게 알리는 길이의 최종값은 flush가
|
||||
> 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 단일 함수로는 동시에
|
||||
> 만족되지 않는다.** 책임 목록·순서 제약의 출처·분해 후보는
|
||||
> `research/slot-attach-decomposition.md`에 정리해뒀다. **M2/M3를 막지는
|
||||
> 않지만 M6(`:List`) 구현 전엔 결론이 나 있어야 한다.**
|
||||
> **✅ [해소, 2026-08-21] `attachSlot`의 책임 분해 — 분해로 확정, 아래
|
||||
> 반영 완료.** 이 함수 하나가 책임을 일곱 개(부모 등록 offset/length,
|
||||
> `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀) 지고
|
||||
> 있다는 사용자 지적에서 시작했고 — *"attachSlot 의 기능이 너무 다양해진게
|
||||
> 문제같음"* — 진단은 **"부모에게 알리는 길이의 최종값은 flush가 끝나야
|
||||
> 정해진다"와 "부기가 물리 조작보다 먼저"가 단일 함수로는 동시에 만족되지
|
||||
> 않는다**는 것이었다(`setLength` 슬롯이 하나뿐이라 원리적으로 불가능).
|
||||
> **결론: `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해하고
|
||||
> `attachSlot`은 그 둘을 부르는 두 줄짜리 래퍼로 남긴다** — 이름/시그니처/
|
||||
> 호출부 전부 불변. 근거 기록은 `research/slot-attach-decomposition.md`.
|
||||
|
||||
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는
|
||||
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할
|
||||
수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`의
|
||||
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그
|
||||
해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래
|
||||
코드의 `blocker` 관련 줄).
|
||||
해법이 **`materializeSlotTree`의 등록 루프**에 어떻게 적용되는지만
|
||||
다룬다(아래 코드의 `blocker` 관련 줄) — **[2026-08-21]** 분해 전엔 이 게이팅이
|
||||
`attachSlot` 본체에 있었고, 물리 마운트 쪽(`mountSlotTree`)은 Blocker가
|
||||
필요 없다.
|
||||
|
||||
`base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는
|
||||
owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나
|
||||
|
|
@ -1716,8 +1808,8 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
|
|||
— `setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.**
|
||||
`base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출
|
||||
순서는 `setOffsetSource` → `setLength`"** 일반 규칙(해제 시점 계약에서
|
||||
나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속
|
||||
어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게
|
||||
나왔지만 등록 시점에도 그대로 적용)과 이 의사코드(**[2026-08-21]** 분해
|
||||
후엔 `materializeSlotTree`)가 계속 어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게
|
||||
되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각
|
||||
요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset
|
||||
전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length`는 `activateList`가
|
||||
|
|
@ -1777,6 +1869,16 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
|||
Dispatch.setLength(slot, i, 1)
|
||||
end
|
||||
end
|
||||
-- [2026-08-21 감사] 위 재귀가 **예외를 던지면 이 줄에 도달하지 못해
|
||||
-- Blocker가 켜진 채 남는다** — 그 Slot의 `recompute`가 이후 영원히
|
||||
-- 게이팅돼 `Length`가 영구 stale해진다. `pcall`로 감싸지 않는 것이
|
||||
-- **사용자 판단(2026-08-21)**: 마운트 도중 예외는 quad가 복구를 보장하지
|
||||
-- 않는 상태이고(에러 경계는 `base/fallback-plan.md`의 `Fallback`/
|
||||
-- `Traceback`이 담당), 아직 실제로 밟은 적 없는 경로다 —
|
||||
-- `conventions.md`의 "드문 오용이나 가상의 미래 요구까지" 절이 세운
|
||||
-- 원칙 그대로. 실제로 물리면 그때 넣는다.
|
||||
-- 참고: 이건 이번 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도
|
||||
-- 있었을 구조적 갭이다(옛 코드도 같은 구간에 정리 코드가 없었음).
|
||||
blocker:OffWithoutEmit()
|
||||
local bk = getBookkeeping(slot)
|
||||
if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정
|
||||
|
|
@ -1791,22 +1893,10 @@ end
|
|||
local function mountSlotTree(slot, physicalTarget)
|
||||
slot._mounted = true
|
||||
slot._mountedInst = physicalTarget
|
||||
-- [2026-08-21] Detach 홀드분의 최종 처분 경로 — physicalTarget이 죽을 때
|
||||
-- `slot._detached`를 비운다(아래 "Detach 요소는 slot._detached가 보유한다" 절).
|
||||
-- Effect가 유일한 도구인 이유: bindLifetime은 "실행해도 되는가"만 게이팅할 뿐
|
||||
-- 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`).
|
||||
slot._detachCleanup = Effect(function()
|
||||
return function()
|
||||
for key, element in pairs(slot._detached) do
|
||||
if slot._owned ~= false then
|
||||
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
|
||||
end
|
||||
slot._detached[key] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
bindLifetime(physicalTarget, slot._detachCleanup)
|
||||
|
||||
-- [이관, 2026-08-21] `_detachCleanup` Effect 설치가 여기 있었으나
|
||||
-- `activateList`로 옮겼다 — `_detached`를 채우는 건 `:List`의 `settle`뿐이라
|
||||
-- List 없는 Slot마다 no-op Effect를 심고 있었다(위 그 함수의 주석이 소스).
|
||||
-- 그래서 이 함수는 이제 **정말로 물리 대입만** 한다.
|
||||
for i, element in ipairs(slot._elements) do
|
||||
if isSlot(element) then mountSlotTree(element, physicalTarget)
|
||||
else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행
|
||||
|
|
@ -1916,14 +2006,16 @@ local function unmountSlotTree(slot)
|
|||
unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제
|
||||
end
|
||||
end
|
||||
-- [2026-08-21] Detach 정리용 Effect도 같이 푼다 — 안 풀면 **옛
|
||||
-- physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이 Slot의
|
||||
-- `_detached`를 파괴**한다(포탈 경로에서 실제로 터짐). 재마운트 때
|
||||
-- mountSlotTree가 새 target에 다시 건다.
|
||||
if slot._detachCleanup then
|
||||
unbindLifetime(slot._detachCleanup)
|
||||
slot._detachCleanup = nil
|
||||
end
|
||||
-- [2026-08-21] `activateList`가 소유하는 두 자원의 **앵커만** 푼다.
|
||||
-- 안 풀면 옛 physicalTarget이 죽을 때, 지금은 다른 곳에 살아있는 이
|
||||
-- Slot의 `:List`가 조용히 멈추고(`_listObserver`) `_detached`가
|
||||
-- 파괴된다(`_detachCleanup`) — 포탈 경로에서 실제로 터진다.
|
||||
-- **핸들과 `_listActivated`는 보존한다**(언마운트는 파괴가 아니다) —
|
||||
-- 구독과 그 클로저 상태(`mounted`/`userdata`/`keyIndex`)는 재마운트
|
||||
-- 후에도 이어져야 하고, 새 target에 다시 걸어주는 건 `activateList`의
|
||||
-- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다.
|
||||
if slot._listObserver then unbindLifetime(slot._listObserver) end
|
||||
if slot._detachCleanup then unbindLifetime(slot._detachCleanup) end
|
||||
-- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고,
|
||||
-- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유).
|
||||
slot._mounted, slot._mountedInst = false, nil
|
||||
|
|
@ -1962,6 +2054,19 @@ local function destroySlotTree(slot)
|
|||
unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음
|
||||
slot._detachCleanup = nil
|
||||
end
|
||||
|
||||
-- [2026-08-21 감사] `:List` 구독도 푼다 — **파괴이므로 `unmountSlotTree`와
|
||||
-- 달리 핸들과 `_listActivated`까지 지운다.** 안 풀면 `gchold[physicalTarget]`이
|
||||
-- observer를(그리고 그 클로저가 붙잡은 `mounted`/`userdata`/`keyIndex`와
|
||||
-- 파괴된 slot 자신을) 계속 강하게 붙잡는다 — 중첩 Slot은 아무리 깊어도
|
||||
-- `physicalTarget`이 트리 최상위 inst 하나라(`materializeSlotTree`가 같은
|
||||
-- 값을 재귀에 그대로 넘김) **자식 Slot만 죽고 그 inst는 살아있는 게 흔한
|
||||
-- 경우**다. 그러면 `data`가 emit될 때마다 이미 죽은 자식들에 대해
|
||||
-- reconcile이 계속 돈다.
|
||||
if slot._listObserver then
|
||||
unbindLifetime(slot._listObserver)
|
||||
slot._listObserver, slot._listActivated = nil, nil
|
||||
end
|
||||
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
|
||||
if bk then
|
||||
for i, observer in pairs(bk.observers) do
|
||||
|
|
@ -2075,8 +2180,8 @@ end
|
|||
동일하게 적용"*). 즉 `bk.N`은 `Dispatch.setLength`가 이전에 본 적
|
||||
없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는
|
||||
건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔
|
||||
`lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`attachSlot`의 flush
|
||||
배치도, Slot의 런타임 단건
|
||||
`lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`materializeSlotTree`의
|
||||
배치 등록도, Slot의 런타임 단건
|
||||
`rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를
|
||||
물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive`의
|
||||
`inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은
|
||||
|
|
@ -2084,7 +2189,7 @@ end
|
|||
등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은
|
||||
규칙의 특수한 안정 상태다.
|
||||
- **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/
|
||||
`attachSlot`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker
|
||||
`materializeSlotTree`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker
|
||||
게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) —
|
||||
그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도
|
||||
안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에
|
||||
|
|
@ -2458,20 +2563,25 @@ state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는
|
|||
그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의
|
||||
코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` +
|
||||
`unbindLifetime` + `releaseOwner`를 부름.
|
||||
2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서
|
||||
비파괴가 되는 건 **값 교체와 `Detach`뿐**이다. `updateFn`이 `nil`/`None`을
|
||||
반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자
|
||||
판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스.
|
||||
2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA;
|
||||
2026-08-21 `Owned` 도입으로 다시 정정]** 이 항목은 원래 "비파괴가 되는
|
||||
건 **값 교체와 `Detach`뿐**"이라고 적혀 있었는데, **값 교체 쪽은
|
||||
틀렸다**. 지금 확정은 **`Detach`만 비파괴**이고, 값 교체·`nil`/`None`·
|
||||
키 소멸은 전부 **`Owned` 플래그가 결정**한다(기본 `true` = 파괴,
|
||||
`false` = 언마운트만). 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절의
|
||||
표가 소스 — 그 표와 이 항목이 어긋나면 표가 맞다.
|
||||
2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은
|
||||
`:List`에는 안 맞는 일반화였음.
|
||||
|
||||
**여전히 파괴인 것**: 명시적 CRUD `Slot:Remove(index)`/`Slot:Clear()`
|
||||
(CRUD 표가 "제거 **+ 파괴**"로 이미 정의), `dispose`, 그리고 위 2번의
|
||||
`:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로
|
||||
지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가
|
||||
"지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로"
|
||||
철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `Detach`로 그 의도를
|
||||
명시한다.
|
||||
`:List` 경로 전부(`Owned = true`일 때 — 값 교체 포함). 즉 일반 규칙은
|
||||
**"자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"**이되,
|
||||
**`:List`가 소유하는 요소는 그 규칙의 예외로 파괴가 기본이다** —
|
||||
`updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지우기 때문(reconcile
|
||||
중엔 `dispose`가 거부됨). `updateFn`이 지우지 않길 원하면 `Detach`로 그
|
||||
의도를 명시하고, **애초에 `:List`의 것이 아닌 요소**(`state<Frame>` 등)는
|
||||
설치 시점에 `Owned = false`로 선언한다.
|
||||
|
||||
`unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식
|
||||
소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
# 스파이크 상태판 — **폴더가 곧 상태**
|
||||
|
||||
> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
|
||||
> 마지막 갱신: **2026-08-21** — QA 4라운드 `F-4-1`로 `Dispatch.drive`의
|
||||
> props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던
|
||||
> `01`이 낡아 `done/` → `rewrite-required/` 이동(검증 대상인 순서 계약
|
||||
> 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이
|
||||
> 없는 실측 항목(`R-11`의 `table.insert` 구멍 재사용, 중간 State GC)을
|
||||
> 여기 모은다. 직전 갱신은 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
|
||||
> 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 →
|
||||
> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성.
|
||||
> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후
|
||||
|
|
@ -62,7 +67,7 @@
|
|||
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
|
||||
아님(계약 자체는 위에서 이미 확정됨).
|
||||
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건)
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건)
|
||||
|
||||
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
|
||||
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
|
||||
|
|
@ -77,8 +82,13 @@
|
|||
`10`은 **Studio 전용이라 재작성해도 이 환경에서는 못 돌린다** — 재작성
|
||||
후 다시 `not-run/`으로 내려가 사용자/MCP를 기다리는 자리다.
|
||||
|
||||
**[2026-08-21] `01`이 합류** — 같은 "설계가 바뀐" 유형이다. 통과 상태로
|
||||
`done/`에 두면 이제는 구현이 안 하는 두 루프 순회를 "검증됨"으로 오독하게
|
||||
된다.
|
||||
|
||||
| 파일 | 상태 | 무엇을 고쳐야 하나 |
|
||||
|---|---|---|
|
||||
| `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 |
|
||||
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
|
||||
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
|
||||
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
|
||||
|
|
@ -93,16 +103,16 @@
|
|||
|---|---|
|
||||
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
|
||||
|
||||
## ✅ `done/` — 통과 or 판정 끝 (19건)
|
||||
## ✅ `done/` — 통과 or 판정 끝 (18건)
|
||||
|
||||
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
|
||||
**런타임 13개 전원 통과**(crash 0 / FAIL 0 — **[2026-08-21]** `01`이
|
||||
`rewrite-required/`로 나가며 14 → 13) — **[열네 번째 세션] `04`/`19`는
|
||||
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
|
||||
`05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13`
|
||||
런타임 절반, PostRef까지 확장)가 합류**:
|
||||
|
||||
| 파일 | 확인된 것 |
|
||||
|---|---|
|
||||
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive`의 순서 계약과 `PreRef` 호이스팅의 전제. **⚠️ [2026-08-21 재작성 필요]** 이 스파이크는 숫자 `for` + 일반화 `for` **두 루프** 버전인데, 구현이 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — 검증 대상(순서 계약)은 그대로이고 스파이크 코드만 그 형태로 다시 쓸 것 |
|
||||
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
|
||||
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
|
||||
| `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 |
|
||||
|
|
@ -157,3 +167,18 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
|
|||
```
|
||||
추측이 아니라 **실제로 GC가 안 됨** — `Slot`의 두-`Relate` 수정이 필수
|
||||
조치였음이 입증.
|
||||
|
||||
|
||||
## 🔵 만들어야 할 스파이크 — 아직 파일이 없음 (2026-08-21 신설)
|
||||
|
||||
폴더가 곧 상태인 이 문서에서 **"아직 파일조차 없는 실측 항목"**은 어느
|
||||
폴더로도 표현되지 않아 그냥 잊혔다. 실제로 QA 4라운드 followup(H-7)이
|
||||
"실측으로 남은 것"의 소스로 이 문서를 지목했는데 여기 항목이 없었다.
|
||||
앞으로 이 절이 그 소스다 — 파일을 만들면 `not-run/` 또는 실행 결과에 따라
|
||||
해당 폴더로 옮기고 여기서 지운다.
|
||||
|
||||
| 검증할 것 | 왜 | 출처 |
|
||||
|---|---|---|
|
||||
| `table.insert`가 배열 중간의 구멍을 재사용하는가 | `Ref` 콜백 배열이 죽은 슬롯을 `None`으로 두는 설계의 전제. 재사용하지 않으면 슬롯이 무한 증가한다 | QA 4라운드 `R-11`, `base/ref-plan.md` |
|
||||
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 |
|
||||
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문
|
||||
|
||||
**상태**: **[2026-08-21] 4차 처리로 종결 — 아래 H절이 최신이자 마지막.**
|
||||
**상태**: **[2026-08-21] 4차 처리로 종결 + 반영 후 감사까지 완료 — 아래
|
||||
I절이 최신.** 감사 4라운드(의사코드 트레이싱)가 **실제 크래시 3건**을 잡아
|
||||
같은 날 전부 닫았다(`I-1`~`I-3`). H절이 반영 내용, I절이 그 감사 결과다.
|
||||
`F-3`이 전량 확인됐고 `attachSlot` 분해도 확정돼 `base/`에 전부 반영됐다.
|
||||
**이 followup에 열린 질문은 남아있지 않다.** 5라운드 문항지는 만들지
|
||||
않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록.
|
||||
|
|
@ -373,6 +375,9 @@ observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것
|
|||
|
||||
### C-1. ⭐ `SL-40`/`SL-43`/`SL-45` — `KeyGone` 센티널 신설
|
||||
|
||||
> **✅ [해소, 2026-08-21] 확정·반영 완료 — 아래 H-2가 결론이다.**
|
||||
> 이 절은 그 제안의 원문이다.
|
||||
|
||||
**사용자 제안**: 키가 데이터에서 사라지면 `updateFn`을 `KeyGone`으로 한 번 더
|
||||
불러 처분을 묻고(`T | KeyGone`), `userdata`를 지울지도 사용자가 정하게 위임.
|
||||
이걸로 `SL-45`(Detach 홀드 중 키 소멸)가 닫힌다.
|
||||
|
|
@ -408,6 +413,9 @@ export**가 일관적이다(`None`/`Detach` 선례). 이름은 `KeyGone`이 의
|
|||
|
||||
### C-2. ⭐⭐ `SL-43` vs `SL-51` — "밀려난 `prev`는 dispose"와 `state<Frame>` 의미론이 충돌한다
|
||||
|
||||
> **✅ [해소, 2026-08-21] `Owned` 설치 플래그로 확정 — 아래 H-3이 결론이다.**
|
||||
> 이 절은 그 충돌을 처음 짚은 원문이다.
|
||||
|
||||
**이번 회신에서 나온 것 중 파급이 가장 크다.** 두 답변이 서로 반대 방향을
|
||||
가리킨다:
|
||||
|
||||
|
|
@ -1140,7 +1148,9 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" =
|
|||
→ 자식 재귀/`setLength` → `OffWithoutEmit` → `recompute` →
|
||||
**마지막에 `setLength(ownerKey, position, slot.Length)`**.
|
||||
- **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과
|
||||
`_mounted = true`, `_detachCleanup` Effect 설치만. Blocker 불필요.
|
||||
`_mounted = true`만. Blocker 불필요. (**[정정, 2026-08-21 `I-7`]** 여기
|
||||
`_detachCleanup` Effect 설치도 있었으나 `activateList`로 이관됐다 —
|
||||
이제 이 함수는 정말로 물리 대입만 한다.)
|
||||
- **공개 `attachSlot`은 그 둘을 순서대로 부르는 두 줄** — 이름/시그니처/호출부
|
||||
전부 그대로라 다른 문서의 참조가 안 깨진다.
|
||||
|
||||
|
|
@ -1175,3 +1185,163 @@ M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠
|
|||
- 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`),
|
||||
`table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는
|
||||
`luau-test/STATUS.md`.
|
||||
|
||||
---
|
||||
|
||||
# I절 — 반영 후 감사 (2026-08-21): 트레이싱이 실제 크래시 3건을 잡음
|
||||
|
||||
H절의 반영을 커밋한 뒤 `quad-doc-auditor`를 각도를 바꿔 4라운드 돌렸다.
|
||||
1~3라운드(문서 대조)에서 나온 것은 전부 "고친 결정이 다른 자리에 안
|
||||
옮겨졌다" 유형이었고, **4라운드(의사코드 시나리오 손 트레이싱)에서 실제로
|
||||
실행이 깨지는 결함 3건**이 나왔다. 셋 다 **`Detach`가 신설한 새 경로가
|
||||
기존 불변식과 부딪히는데 그쪽이 안 고쳐진 것**이다.
|
||||
|
||||
## I-1. detach 재마운트가 `claimOwner`에서 무조건 크래시 (⭐⭐ 치명)
|
||||
|
||||
`rawDetach`는 일부러 `releaseOwner`를 안 부른다(소유권 유지가 설계의
|
||||
핵심). 그런데 재마운트는 `rawAdd`를 거치고, `rawAdd`가 부르는
|
||||
`claimOwner`는 **같은 owner의 재클레임도 무조건 error**다 — 2026-08-13
|
||||
감사가 `Slot{a, a}`를 막으려고 일부러 엄격하게 만든 것이다. 결과:
|
||||
**문서가 권장하는 "다음 사이클에 `prev`를 그대로 돌려주면 재마운트" 패턴이
|
||||
그대로 `error("이 요소는 이미 마운트돼 있음")`로 죽는다.**
|
||||
|
||||
**확정(사용자 판단)**: `claimOwner`에 **detach 재마운트 전용 예외**를 넣는다
|
||||
— `claimOwner(element, ownerKey, fromDetached)`에서 `fromDetached and
|
||||
cur == ownerKey`일 때만 통과. top-level `claimOwnerAt`이 이미 "같은
|
||||
`(inst, k)` 자리의 재발행은 통과"라는 같은 모양의 예외를 갖고 있어 대칭이
|
||||
맞는다. **`fromDetached` 없이 "같은 owner면 통과"로 완화하면 안 된다** —
|
||||
`Slot{a, a}`가 다시 새어나간다.
|
||||
|
||||
## I-2. 재마운트된 자식 Slot이 `activateList`를 두 번 실행 (⭐⭐)
|
||||
|
||||
`I-1`을 고치면 바로 드러나는 다음 문제. `rawAdd`는 Slot 요소를 이미
|
||||
마운트된 부모에 넣을 때 `attachSlot`을 부르고, `materializeSlotTree`는
|
||||
`slot._listed`면 **무조건** `activateList`를 다시 실행한다. `:List`를 가진
|
||||
자식 Slot이 detach에서 돌아오면 `data:Observer` 구독이 하나 더 생기고
|
||||
`mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져**
|
||||
기존 요소를 전부 새 것으로 오인해 다시 그린다.
|
||||
|
||||
**확정(사용자 판단)**: `activateList`에 **멱등 가드**(`slot._listActivated`).
|
||||
`_crudUsed`/`_listed`와 같은 결의 플래그다.
|
||||
|
||||
**가드만으로는 반쪽이라 하나 더 닫았다** — `:List`의 `data:Observer`는
|
||||
`bindLifetime(inst, observer)`로 **물리 target에 앵커**돼 있는데,
|
||||
`unmountSlotTree`가 푸는 건 `bk.observers`뿐이라 이 구독은 **옛
|
||||
physicalTarget에 매달린 채** 남는다. 포탈로 다른 target에 재마운트하면
|
||||
옛 target이 죽는 순간 살아있는 Slot의 `:List`가 조용히 반응을 멈춘다.
|
||||
그래서 `slot._listObserver`로 핸들을 보관하고, `unmountSlotTree`가
|
||||
`unbindLifetime`만 하고(핸들과 `_listActivated`는 보존), 멱등 가드가
|
||||
`bindLifetime(inst, self._listObserver)`로 앵커를 새 target에 다시 건다.
|
||||
`_detachCleanup`이 이미 받고 있던 처리와 같은 모양이다.
|
||||
|
||||
## I-3. `_detachCleanup`이 `releaseOwner`를 안 부름 (⭐)
|
||||
|
||||
owner가 죽을 때 `_detached`를 비우는 `Effect`가 `_owned == false` 분기에서
|
||||
요소를 파괴하지 않는 건 맞는데, **소유권 기록도 안 푼다.** 그러면 그
|
||||
`state<Frame>`이 **죽은 Slot을 owner로 달고** 남아, 사용자가 같은 값을
|
||||
다른 Slot에 넣을 때 그 죽은 Slot이 GC되기 전까지 비결정적으로 "이미
|
||||
마운트돼 있음" error가 난다. 이 문서 스스로 "소유권 반납은 GC에 맡기면
|
||||
안 됨" 절에서 경계했던 실패 모드다.
|
||||
|
||||
**반영**: 두 분기 공통으로 `releaseOwner(element, slot)`를 먼저 부른다
|
||||
(`rawRemove`도 파괴 전에 부르므로 대칭).
|
||||
|
||||
## I-4. `materializeSlotTree` 중 예외 시 Blocker가 켜진 채 남음 — 문서화만
|
||||
|
||||
`blocker:On()`과 `OffWithoutEmit()` 사이에서 자식 재귀가 예외를 던지면
|
||||
Blocker가 영구히 켜져 그 Slot의 `Length`가 영원히 stale해진다.
|
||||
|
||||
**확정(사용자 판단)**: `pcall`로 감싸지 않고 **문서화만 한다.** 마운트
|
||||
도중 예외는 quad가 복구를 보장하지 않는 상태이고(에러 경계는
|
||||
`base/fallback-plan.md`), 아직 실제로 밟은 적 없는 경로다 —
|
||||
`conventions.md`의 "드문 오용이나 가상의 미래 요구까지" 절이 세운 원칙
|
||||
그대로. 이건 이번 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도
|
||||
있었을 구조적 갭이다.
|
||||
|
||||
## I-5. 문서 대조 라운드(1~3)에서 나온 것
|
||||
|
||||
전부 "고친 결정이 다른 자리에 안 옮겨졌다" 유형이라 여기 나열하지 않는다
|
||||
— 커밋 diff가 소스. 대표적인 것만: `slot-plan.md`가 한쪽에선 `Owned`
|
||||
기준 표를, 다른 쪽에선 옛 "값 교체는 비파괴"를 동시에 서술하고 있었고,
|
||||
`attachSlot`의 flush 루프를 가리키던 문장 5곳이 `materializeSlotTree`로
|
||||
안 옮겨져 한 문서 안에 신/구 표현이 공존했으며, `luau-test/STATUS.md`가
|
||||
자기 규칙("폴더가 곧 상태")을 어기고 재작성 대상 스파이크를 `done/`에
|
||||
두고 있었다.
|
||||
|
||||
## I-6. 5라운드(수정분 회귀 트레이싱) — 3건 더, 전부 반영
|
||||
|
||||
`I-1`~`I-3`의 수정 자체를 다시 트레이싱한 라운드. 새 결함은 안 나왔지만
|
||||
**그 수정이 닿았어야 할 자리 3곳**이 나왔다.
|
||||
|
||||
1. **`destroySlotTree`가 `_listObserver`를 안 푼다.** `unmountSlotTree`엔
|
||||
넣었는데 파괴 경로엔 빠졌다. `physicalTarget`은 중첩 깊이와 무관하게
|
||||
**트리 최상위 inst 하나**라(`materializeSlotTree`가 같은 값을 재귀에
|
||||
그대로 넘김), 자식 Slot만 죽고 그 inst는 살아있는 게 흔한 경우 —
|
||||
그러면 `gchold[inst]`가 observer와 그 클로저 상태(그리고 죽은 slot
|
||||
자신)를 계속 붙잡고, `data`가 emit될 때마다 이미 죽은 자식들에 대해
|
||||
reconcile이 계속 돈다. **반영**: 파괴이므로 `unmountSlotTree`와 달리
|
||||
핸들과 `_listActivated`까지 `nil`로 지운다.
|
||||
2. **`claimOwner`의 옛 논증 두 문단이 `fromDetached`와 정면 모순.**
|
||||
2026-08-13 세션의 *"nested엔 재클레임이라는 개념이 애초에 없다 …
|
||||
무조건 error가 맞음"*이 그대로 남아 있었다 — 그 논증의 근거(reconcile은
|
||||
항상 release → claim 순서)가 `Detach`로 깨졌는데 갱신이 안 따라갔다.
|
||||
함수 정의 옆 주석만 정정돼 있었다. **반영**: 두 문단에 ⚠️ 정정을 달고,
|
||||
"플래그 없이 같은 owner면 통과로 완화하면 `Slot { a, a }`가 다시
|
||||
새어나간다"는 경계도 같이 명시.
|
||||
3. **소유권 예시 코드가 `C-4`와 모순.** `-- destroySlotTree(slot) 안,
|
||||
자식들을 파괴하기 직전` + `releaseOwner(element, slot)` 예시가
|
||||
2026-08-12 서술로 남아 있었는데, 2026-08-20 `C-4`가 그 호출을 도로
|
||||
뺐고 실제 의사코드도 "부르지 **않는다**"라고 명시한다. 이번 감사
|
||||
각도(`releaseOwner` 이중 호출 추적)에서 걸렸다. **반영**: 예시를
|
||||
삭제 표시로 교체.
|
||||
|
||||
**회귀 없음으로 확인된 것**: `claimOwner`의 early return은 `OWNER_POS`를
|
||||
stale하게 남기지 않고(그 필드는 top-level `claimOwnerAt` 전용),
|
||||
`fromDetached`를 안 넘기는 기존 호출부는 동작이 이전과 완전히 동일하며,
|
||||
`_listActivated` 가드는 정상 마운트/`Slot:List()` 경로를 막지 않고,
|
||||
언마운트~재마운트 구간에도 `_listObserver`는 Slot 필드 강참조라 GC되지
|
||||
않으며, `releaseOwner` 이중 호출 경로는 없다(`destroySlotTree`가 `C-4`
|
||||
이후 자식에 대해 안 부르므로).
|
||||
|
||||
|
||||
## I-7. `_detachCleanup`을 `activateList`로 이관 + `activateList`의 `inst` 리네이밍
|
||||
|
||||
사용자가 별도 에이전트와 상의한 내용을 가져와 확정한 두 건. 둘 다 **의미
|
||||
변화 없는 배치/이름 정리**지만, 첫 건은 실제 낭비를 없앤다.
|
||||
|
||||
1. **`_detachCleanup` Effect 설치가 `mountSlotTree` → `activateList`로 이관.**
|
||||
`_detached`를 채우는 유일한 지점이 `settle`의 `rawDetach`이고 그건
|
||||
`activateList` 클로저 안에만 있으므로, **`:List`가 없는 Slot의
|
||||
`_detached`는 정의상 영원히 빈 테이블**이다. 그런데 `mountSlotTree`는
|
||||
재귀 전체(중첩 포함)에 Effect + `bindLifetime`을 심고 있었다 — 트리
|
||||
크기만큼의 no-op. 확인: `rawDetach` 호출부는 `settle` 한 곳뿐이고
|
||||
`_detached`에 쓰는 코드도 전부 그 클로저 안이다.
|
||||
|
||||
**이관하면서 `_listObserver`와 완전히 같은 취급으로 통일했다** —
|
||||
생성은 `activateList` 최초 1회, `unmountSlotTree`는 **앵커만 해제하고
|
||||
핸들 보존**, 재마운트는 멱등 가드가 재앵커, `destroySlotTree`만 해제 +
|
||||
`nil`. 이전엔 이 둘이 서로 다른 함수에서 만들어지고 언마운트 처분도
|
||||
갈렸다(하나는 보존, 하나는 `nil`). 이제 **"`activateList`가 소유하고
|
||||
물리 target에 앵커되는 자원"**이라는 한 범주가 되어, 이런 자원이 또
|
||||
생겨도 훑을 자리가 하나다.
|
||||
|
||||
**⚠️ 이 이관에는 `I-2`의 멱등 가드와의 상호작용이 걸려 있다.** 원
|
||||
제안의 근거는 *"`materializeSlotTree`가 `_listed`면 재마운트 때도
|
||||
`activateList`를 다시 부르니 lifecycle이 보존된다"*였는데, 그건
|
||||
**가드를 넣기 전 동작**이다(그리고 그게 `I-2`의 버그였다). 가드가 있는
|
||||
지금은 두 번째 호출이 early return하므로, **가드 분기가
|
||||
`_detachCleanup`도 같이 재앵커하지 않으면** 포탈 재마운트 후 그 Slot의
|
||||
detached는 owner 사망 시 아무도 치우지 않는다. 가드 분기에서 두 자원을
|
||||
같이 `bindLifetime`하도록 반영했다.
|
||||
|
||||
`_detached` **테이블 자체는 Slot 필드로 그대로 둔다** —
|
||||
`destroySlotTree`가 `activateList` 클로저 **밖**에서 walk해야 하는 게
|
||||
애초에 `ud`를 버리고 필드로 간 이유다(`I-1`의 (2)번 근거).
|
||||
|
||||
2. **`activateList(self, inst)` → `activateList(self, physicalTarget)`.**
|
||||
그 인자는 `bindLifetime`의 첫 인자로 들어가니 타입상 물리 Instance일
|
||||
수밖에 없고, 호출부 둘(`materializeSlotTree`의 `physicalTarget`,
|
||||
`Slot:List()`의 `self._mountedInst`)이 넘기는 값도 같은 개념이다.
|
||||
Slot일 수는 없다 — 그 Slot은 이미 1번째 인자 `self`다. 옆 함수들
|
||||
(`materializeSlotTree`/`mountSlotTree`/`attachSlot`)과 이름을 맞춘
|
||||
**순수 리네이밍**.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
# 구현 전 QA **4라운드** — 확정 전수 재심사 문항지
|
||||
|
||||
**상태**: **작성 중 / 사용자 회신 대기**(2026-08-19 세션에서 생성).
|
||||
**상태**: **[2026-08-21] 완료·종결.** 2026-08-19 세션에서 생성 → 사용자가
|
||||
`-response.md`로 회신 → 4차에 걸친 처리로 전량 반영 완료. **처리 결과의
|
||||
소스는 `pre-implementation-qa-round4-followup.md`**(마지막 H절이 최신) —
|
||||
이 파일은 문항지 원본 그대로 남긴다.
|
||||
|
||||
**[2026-08-21 계획 변경] 아래 "회신 방법" 문단이 예고한 "아니오가 나온
|
||||
항목만 남긴 근거 기록으로 재편"은 하지 않기로 했다** — 1라운드는 회신을
|
||||
문항지 안에 받아서 재편이 곧 정리였지만, 이 라운드는 회신을 별도 파일로
|
||||
받았고 처리 결과가 전부 `-followup.md`에 쌓였다. 여기서 또 재편하면 같은
|
||||
정보가 두 곳으로 갈라져 이 코퍼스에서 반복적으로 터진 "개수·목록 이중 소스"
|
||||
패턴을 새로 만든다.
|
||||
|
||||
**왜 이 라운드가 있는가**: 사용자 요청 — *"요즘 변경이 엄청 많고, 틀려서
|
||||
정정한게 엄청 많아서, 모든 부분에 있어서 내 심사를 좀 받아야할듯. 예 가
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@
|
|||
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
|
||||
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
|
||||
헷갈릴 수 있음.
|
||||
- **`Owned`(3순위, 2026-08-21 신설)**: `:List`/`:Single`의 설치 시점
|
||||
플래그(기본 `true`, `false`면 어떤 경로로도 파괴 안 함).
|
||||
`elementOwner`/`claimOwner`/`releaseOwner`와 같은 뿌리라 골랐지만
|
||||
**잠정 이름**이다 — 형용사라 옵션 테이블 키로는 자연스러운데, 실제로
|
||||
묻는 건 "이 Slot이 요소의 수명을 책임지는가"라서 `OwnsElements`처럼
|
||||
주어를 드러내는 쪽이 나을 수도 있음. `base/slot-plan.md`의
|
||||
"소유권은 설치 시점에 정해진다" 절.
|
||||
- **`canExecute`(3순위, 사소함)**: 실제로 "이 값이 아직 살아있나" 확인인데
|
||||
이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이
|
||||
있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열
|
||||
|
|
@ -168,7 +175,8 @@
|
|||
선택지 (c)로 확정: `updateFn`을 **`KeyGone`으로 한 번 더 불러 처분을
|
||||
묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라
|
||||
**`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가
|
||||
닿고 소유권도 유지됨), owner가 죽으면 `Effect`가 정리한다. 원래 갭이
|
||||
닿고 소유권도 유지됨), owner가 죽으면 `activateList`가 설치한 `Effect`가
|
||||
정리한다. 원래 갭이
|
||||
치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가
|
||||
**GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`의
|
||||
"Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# `attachSlot` 책임 분해 — 확장 논의 준비 자료
|
||||
# `attachSlot` 책임 분해 — 결정 근거 기록 (2026-08-21 확정)
|
||||
|
||||
**상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`에
|
||||
반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한
|
||||
|
|
|
|||
|
|
@ -137,3 +137,96 @@ detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어
|
|||
`table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`.
|
||||
- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는
|
||||
**일부러 미뤘다** — 지금 그게 아프다는 증거가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 반영 후 감사 — 트레이싱 각도가 문서 대조로는 안 보이던 크래시를 잡았다
|
||||
|
||||
H절 반영을 커밋한 뒤 사용자 요청으로 `quad-doc-auditor`를 **6라운드** 돌렸다
|
||||
(관례대로 한 턴에 하나씩, 라운드마다 각도를 바꿈). 발견 추이:
|
||||
|
||||
| 라운드 | 각도 | 확실 발견 |
|
||||
|---|---|---|
|
||||
| 1 | `base/slot-plan.md` 내부 문장 정합성 | 4 |
|
||||
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS) | 6 |
|
||||
| 3 | `archive/` + 바깥 `base/` + `reference/` | 2 |
|
||||
| 4 | **의사코드 시나리오 손 트레이싱** | **3 (실제 크래시)** |
|
||||
| 5 | 그 수정분의 회귀 트레이싱 | 3 |
|
||||
| 6 | 수렴 확인 | **0** |
|
||||
|
||||
**이 표에서 배울 것은 "단조 감소하지 않았다"는 점이다.** 1~3라운드는 전부
|
||||
"고친 결정이 다른 자리에 안 옮겨졌다"는 **문서 정합성** 유형이었고, 각도를
|
||||
트레이싱으로 바꾼 4라운드에서야 **실행이 깨지는 결함**이 나왔다. 문서 대조를
|
||||
몇 번 더 돌렸어도 이건 안 나왔을 것이다 — 세 건 다 각각의 문장은 정확했고,
|
||||
**서로 다른 두 문서의 정확한 문장이 한 실행 경로에서 만나면 죽는** 종류였기
|
||||
때문이다.
|
||||
|
||||
### 4라운드가 잡은 것의 공통 구조
|
||||
|
||||
셋 다 **`Detach`가 신설한 새 경로가 기존 불변식과 부딪히는데 그쪽이 안
|
||||
고쳐진 것**이다. 특히 `I-1`이 뼈아프다:
|
||||
|
||||
- `rawDetach`는 **일부러** `releaseOwner`를 안 부른다 — 소유권 유지가 이번
|
||||
세션 설계의 핵심이었다.
|
||||
- `claimOwner`는 **일부러** 같은 owner의 재클레임도 error다 — 2026-08-13
|
||||
감사가 `Slot { a, a }`를 막으려고 엄격하게 만든 것이다.
|
||||
- 둘 다 각자 맞는데, **"`prev`를 그대로 돌려주면 재마운트"라는 이 세션이
|
||||
확정한 권장 패턴이 정확히 그 교차점을 지난다.** 그대로 구현했으면
|
||||
문서가 권장하는 사용법이 첫 실행에서 죽었다.
|
||||
|
||||
`claimOwner`의 옛 논증 문단이 *"nested에서 '이미 내가 갖고 있는 걸 다시
|
||||
클레임'하는 정당한 경로가 하나도 없으므로 무조건 error가 맞음"*이라고
|
||||
단정하고 있었는데, 이번 세션이 그 "하나도 없는" 경로를 만들어놓고 그
|
||||
문단을 안 읽은 것이다. 5라운드가 그 문단 자체를 따로 잡아냈다.
|
||||
|
||||
### 내가 감사 결론을 한 번 넘어선 곳
|
||||
|
||||
`I-2`의 멱등 가드는 사용자가 고른 안이지만, 그것만으로는 반쪽이었다.
|
||||
`:List`의 `data:Observer`는 `bindLifetime(inst, observer)`로 **물리 target에
|
||||
앵커**돼 있는데 `unmountSlotTree`가 푸는 건 `bk.observers`뿐이라, 가드만
|
||||
넣으면 포탈 재마운트 후 **옛 target이 죽는 순간 살아있는 Slot의 `:List`가
|
||||
조용히 반응을 멈춘다.** `_listObserver` 핸들을 보관해 앵커만 옮기는 방식으로
|
||||
마저 닫았고, 이건 `_detachCleanup`이 이미 받고 있던 처리와 같은 모양이라
|
||||
새 패턴을 만든 것도 아니다. 5라운드가 그 수정의 **파괴 경로 누락**(
|
||||
`destroySlotTree`에는 안 넣은 것)을 다시 잡았다 — 비대칭 처리를 하나
|
||||
추가하면 그 짝을 반드시 같이 훑어야 한다는 걸 다시 확인.
|
||||
|
||||
### 사용자가 판단한 것 (선택지 3개 제시 → 전부 추천안 채택)
|
||||
|
||||
1. **재마운트 경로** → `claimOwner`에 `fromDetached` 예외(대안: 전용
|
||||
`rawReattach` 신설 / `rawDetach`가 소유권을 놓게 변경).
|
||||
2. **`activateList` 재실행** → 멱등 가드(대안: 인자로 전달 / 전용 경로에서만).
|
||||
3. **`materializeSlotTree` 중 예외 시 Blocker 잔류** → **문서화만 하고
|
||||
`pcall` 안 씀.** 근거는 `conventions.md`의 "드문 오용이나 가상의 미래
|
||||
요구까지" 절 — 아직 실제로 밟은 적 없는 경로이고, 에러 경계는
|
||||
`base/fallback-plan.md`가 담당한다. 이건 이번 분해가 만든 창이 아니라
|
||||
옛 단일 `attachSlot`에도 있었을 구조적 갭이다.
|
||||
|
||||
### 부수 정리
|
||||
|
||||
2라운드가 `luau-test/STATUS.md`의 자기 규칙 위반을 잡았다 — "폴더가 곧
|
||||
상태"라면서 재작성 대상 스파이크 `01`을 `done/`에 둔 채 ⚠️ 마커만 달아둔
|
||||
것. `git mv`로 실제 이동시키고 개수를 맞췄으며, 같이 **`만들어야 할 스파이크`
|
||||
절을 신설**했다. "아직 파일조차 없는 실측 항목"은 어느 폴더로도 표현이 안 돼
|
||||
구조적으로 잊히는 자리였고, 실제로 followup H-7이 그 소스로 STATUS.md를
|
||||
지목했는데 정작 항목이 없었다.
|
||||
|
||||
### 감사 종료 후 하나 더 — 사용자가 다른 에이전트와 상의해 가져온 두 건
|
||||
|
||||
`_detachCleanup` Effect 설치를 `mountSlotTree` → `activateList`로 이관,
|
||||
그리고 `activateList(self, inst)`의 2번째 인자를 `physicalTarget`으로
|
||||
리네이밍. 상세는 followup `I-7`.
|
||||
|
||||
**여기서 짚어둘 것은 상호작용 하나다.** 이관 제안의 근거가
|
||||
*"`materializeSlotTree`가 `_listed`면 재마운트 때도 `activateList`를 다시
|
||||
부르니 lifecycle이 보존된다"*였는데, **그건 오늘 오후에 내가 멱등 가드를
|
||||
넣기 전의 동작**이고 사실 그게 `I-2`의 버그였다. 가드가 있는 지금은 두 번째
|
||||
호출이 early return하므로, 가드 분기가 `_detachCleanup`도 같이 재앵커하지
|
||||
않으면 포탈 재마운트 후 detached가 영영 안 치워진다. 결론(이관)은 맞지만
|
||||
근거가 한 세대 낡아 있었고, 그 자리를 메워 반영했다.
|
||||
|
||||
부수적으로 `_listObserver`와 `_detachCleanup`이 **같은 범주로 통일**됐다 —
|
||||
"`activateList`가 소유하고 물리 target에 앵커되는 자원"(생성 1회 / 언마운트는
|
||||
앵커만 해제하고 핸들 보존 / 재마운트는 가드가 재앵커 / 파괴만 `nil`).
|
||||
오늘 5라운드가 잡은 게 "비대칭 처리를 하나 추가하면 그 짝을 반드시 같이
|
||||
훑어야 한다"였는데, 아예 비대칭을 없애는 쪽으로 정리된 셈이다.
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@
|
|||
`archive/question-resolved.md`.
|
||||
- **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 —
|
||||
**`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는
|
||||
`slot._detached` 필드가 보유하고 owner 사망 시 `Effect`가 정리.
|
||||
`slot._detached` 필드가 보유하고 owner 사망 시 `activateList`가 건
|
||||
`Effect`가 정리.
|
||||
`base/slot-plan.md`의 "`KeyGone`" 절.
|
||||
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
|
||||
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
|
||||
|
|
@ -167,12 +168,11 @@
|
|||
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는
|
||||
2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
|
||||
목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
|
||||
— 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영
|
||||
완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영
|
||||
완료로 목록에서 빠짐**): `Slot`(2순위),
|
||||
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지
|
||||
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/
|
||||
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
|
||||
— **[2026-08-21] 여기 있던 이름 나열은 지웠다.** 바로 위 문장이 이미
|
||||
"`question.md` 1번이 최신 소스"라고 선언해놓고 다음 줄에서 목록을 다시
|
||||
나열하고 있었고, 예고대로 실제로 갈라졌다(2026-08-21에 추가된 `Owned`와
|
||||
그 전부터 있던 `hintValue`가 둘 다 빠져 있었음 — 감사가 발견).
|
||||
**열린 항목이 뭔지는 `question.md` 1번을 열어볼 것.**
|
||||
3. **[2026-08-14 세션에 해소]** 오래 열려 있던 "이미 생성된 인스턴스
|
||||
재바인드"는 **기각**되어 `archive/existing-instance-bind-rejected.md`로
|
||||
이전됨 — 더 이상 상의할 스코프 항목이 아님.
|
||||
|
|
|
|||
|
|
@ -529,7 +529,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
"Detach된 요소는 `slot._detached`가 보유한다" 절).
|
||||
**[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은
|
||||
`KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`로
|
||||
한 번 더 물어 처분을 받고, owner가 죽으면 `mountSlotTree`가 건
|
||||
한 번 더 물어 처분을 받고, owner가 죽으면 `activateList`가 건
|
||||
`Effect`가 `_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절).
|
||||
**`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본
|
||||
`true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가
|
||||
|
|
|
|||
Loading…
Reference in a new issue