From 9b7f847014a644a177ce2afdff25018545aeba4f Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 21 Aug 2026 11:55:11 +0900 Subject: [PATCH] =?UTF-8?q?design:=20Detach=20=EB=B3=B4=EC=A1=B4=20?= =?UTF-8?q?=EC=A3=BC=EC=B2=B4/KeyGone/Owned=20=ED=99=95=EC=A0=95=20+=20att?= =?UTF-8?q?achSlot=20=EB=B6=84=ED=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA 4라운드 followup의 마지막 열린 항목(F-3)이 사용자 회신으로 전량 닫히면서 :List 요소 소유권 모델과 attachSlot 책임 분해를 base/에 반영. - Detach 보존 주체를 userdata → slot._detached 필드로 전면 정정. 근거는 gcconn 트릭 — detach된 quad-제작 Instance는 GC 폴백이 없어 명시적 정리 경로가 필수인데 userdata는 :List에게 opaque라 처분 불가. 재-Detach는 nop, prev 반환은 재마운트. raw 3형제(rawRemove/rawUnmount/ rawDetach)로 "파괴하는가"와 "소유권을 놓는가"를 분리. - KeyGone 센티널 신설 — 키가 사라진 자리도 조용히 처분하지 않고 updateFn(KeyGone, 0, offset, prev, ud)로 한 번 더 묻는다. owner 사망 시 최종 정리는 mountSlotTree가 거는 Effect가 담당. - Owned 설치 플래그 신설 — Detach(사이클 단위)와 직교하는 축. state 의미론 충돌(C-2)이 이걸로 닫힘. - attachSlot을 materializeSlotTree(부기) + mountSlotTree(물리)로 분해. "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 한 함수 안에선 동시 만족 불가라는 진단이 근거. 공개 표면은 두 줄짜리 래퍼로 유지해 호출부 무변경. research/slot-attach-decomposition.md 확정. - ROADMAP M6의 옛 Detach 서술 2건과 미결 마커 정정, question.md/todos.md 해소 반영, session/2026-08-21-01 원문 + session-summary 색인 공백 4건 보강. doc-check.py ERROR 0. Co-authored-by: qwreey --- .claude/base/blocker-plan.md | 4 +- .claude/base/dispatch-core-plan.md | 12 +- .claude/base/slot-plan.md | 477 +++++++++++++----- .../pre-implementation-qa-round4-followup.md | 110 +++- .claude/question.md | 24 +- .claude/research/slot-attach-decomposition.md | 19 +- .claude/session-summary.md | 44 ++ ...gone-owned-and-attachslot-decomposition.md | 139 +++++ .claude/todos.md | 34 +- ROADMAP.md | 49 +- 10 files changed, 726 insertions(+), 186 deletions(-) create mode 100644 .claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 54a83cc..3732703 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -167,7 +167,9 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 **base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** — 위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset 배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`을 -재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** — +재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의 +`materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다) +**그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** — 부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도 Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의 `OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 57a6747..e30bed3 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -1381,7 +1381,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이 동일하게 적용"* — 즉: - `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`를 등록할 때마다 `bk.N`이 `i`로 늘어난다(`Dispatch.drive`의 배열 파트 - 순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이 + 순회, `materializeSlotTree`의 등록 배치, Slot의 런타임 단건 `rawAdd` 전부 이 하나의 규칙) — **`Dispatch.setOffsetSource`는 `bk.N`을 건드리지 않는다**, 호출 순서가 항상 `setOffsetSource(i)` → `setLength(i)`라서 (아래 "`setLength` 구현" 절) `bk.N`을 `setLength`에서만 올려야 @@ -1396,7 +1396,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이 케이스가 아니라 같은 규칙의 특수한 안정 상태다. **이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치 -등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을 +등록 중(`Dispatch.drive`/`materializeSlotTree`의 등록 루프)엔 아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가 `recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로, 배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도 @@ -1645,7 +1645,13 @@ position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보 전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로). **적용 지점 — `Dispatch.drive`와 `attachSlot`, 각각 자기 owner 키로 -별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히 +별도 Blocker** +(**[2026-08-21] `attachSlot` 쪽은 이제 정확히는 그 안의 +`materializeSlotTree`다** — `attachSlot`이 "부기만 만드는 재귀"와 "물리만 +붙이는 재귀" 둘로 분해되면서 Blocker가 **등록 쪽 하나만** 감싸게 됐고, +그래서 "배치 *등록* 게이팅"이라는 이 절의 정의와 실제 범위가 정확히 +일치하게 됐다. 옛 코드는 물리 마운트까지 같이 감싸고 있었음. +`base/slot-plan.md`의 "재귀 메커니즘" 절이 소스): 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히 둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위 `inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의** `_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 — diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 35640c0..5bf1a96 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -471,8 +471,11 @@ releaseOwner(element, slot) 클레임 미접촉) 무조건 error가 맞음 — top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함. -**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawRemove`/ -`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다. `elementOwner`는 +**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨** — `rawUnmount`/ +`rawExtract`처럼 **요소를 살려서 내보내는** 경로에 한해 그렇다(**[표현 정밀화, +2026-08-21]** 여기 `rawRemove`를 같이 적었었는데 그건 파괴 경로다 — +`rawRemove`의 `releaseOwner`는 아래 재정정 기준으로 보면 불필요하지만 요소가 +어차피 죽으므로 무해해서 그대로 둔다). `elementOwner`는 값도 `SetWeak`이라 "아무에게도 참조되지 않게 되면 소유권 기록도 저절로 사라진다"가 원리적으로는 맞지만, **그게 언제인지가 GC 타이밍에 달려 있어서** 그 전에 같은 element를 다른 곳에 넣으려 하면 "이미 마운트돼 있음" error가 @@ -1069,7 +1072,21 @@ lifecycle-pattern.md` "quad는 자신이 만든 Instance의 라이프사이클") Slot 자신)보다 명시적으로 오래 살아야 하는 값을 담으면 안 됨 — GC만으로 자연히 정리되는 값만 담을 것(plain 값, `Source`/`State` 등), `:Subscribe()`한 `Observer`/`Effect`류처럼 명시적 `:Unsubscribe()`가 필요한 값을 담는 건 -UB.** `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의 +UB.** + +**⚠️ [예시 추가, 2026-08-21] quad가 만든 Instance도 "GC만으로 정리되는 값"이 +아니다.** 지금까지 이 절은 `:Subscribe()`한 Observer만 예로 들어서 **Instance는 +안전해 보였는데, 정반대다** — quad는 자기가 만든 Instance마다 gcconn을 걸고 그 +클로저가 `inst`를 캡처하므로("참조를 놓는 것만으로는 회수되지 않고 반드시 +`Destroy`로 회수된다", `base/lifecycle-pattern.md`의 "(0)" 절) **`ud`에 담아둔 +Instance는 아무도 안 들고 있어도 영원히 남는다.** + +- 요소를 잠깐 떼어뒀다 되쓰고 싶으면 **`ud`에 직접 담지 말고 `Detach`를 + 반환**할 것 — 그러면 `slot._detached`가 관리하고 owner가 죽을 때 같이 + 정리된다(위 "Detach된 요소는 `slot._detached`가 보유한다" 절). `Detach`가 + 생긴 뒤로는 `ud`에 Instance를 담을 이유 자체가 없다. +- 그래도 담겠다면 **정리 책임은 전적으로 사용자**다 — `:List`는 `ud` 안을 + 들여다보지 않는다. `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의 무언가가 GC 하나만으로 안 죽는다면 그건 곧 leak. 이건 quad 전역 GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 그대로 적용한 것뿐 — 새 원칙 아님. @@ -1104,6 +1121,40 @@ function activateList(self, inst) local offset = self.Offset local mounted, userdata, keyIndex = {}, {}, {} + -- [2026-08-21] 한 키의 처분을 실제로 수행하는 공통 로직 — 정상 사이클과 + -- 소멸 루프가 같은 걸 쓴다(분기가 두 군데로 갈리면 반드시 어긋남). + local function settle(key, result, detach, pos) + local wasMounted = mounted[key] + local wasDetached = self._detached[key] -- Slot 필드(아래 "Detach된 요소는 `slot._detached`가 보유한다" 절) + local prev = wasMounted or wasDetached + + if detach then + -- "이 자리를 비우되 죽이지 마라". 이미 detach 상태면 **nop**. + if wasMounted ~= nil then + rawDetach(self, wasMounted) -- 언마운트하되 **소유권은 유지** + self._detached[key], mounted[key] = wasMounted, nil + end + elseif result == nil then + -- "지워라". 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) + mounted[key] = result + elseif keyIndex[key] ~= pos then + rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동 + end + else + -- 교체 — 밀려난 prev 처분은 Owned가 정한다 + if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end + self._detached[key] = nil + rawAdd(self, result, pos) + mounted[key] = result + end + end + local function reconcile(items) local newKeyIndex, seen = {}, {} local pos = 0 -- 압축된(실제 마운트된) 위치 카운터, raw 루프 인덱스 i와 다름 @@ -1115,7 +1166,9 @@ function activateList(self, inst) end seen[key] = true - local prev = mounted[key] + -- [2026-08-21] prev는 "이 키의 요소" — 마운트돼 있든 detach돼 있든. + -- 그래서 detach된 걸 그대로 반환하면 재마운트가 된다(settle 참고). + local prev = mounted[key] or self._detached[key] local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능) local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key]) if result == None then result = nil end -- 편의: None도 nil과 동일 취급 @@ -1132,35 +1185,30 @@ function activateList(self, inst) pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1) end - if result ~= prev then - -- [재정정, 2026-08-18 구현 전 QA] 세 경로가 갈린다 — 아래 - -- "`nil` 리턴은 파괴가 기본" 절이 소스: - -- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만** - -- — state 교체와 동형, 지우라고 한 적이 없음. - -- (b) Detach: **언마운트만**, 재사용은 ud가 홀드. - -- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시. - if prev ~= nil then - if result ~= nil or detach then rawUnmount(self, prev) - else rawRemove(self, prev) end - end - if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준 - mounted[key] = result - elseif prev ~= nil and keyIndex[key] ~= pos then - rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동 - end + settle(key, result, detach, pos) userdata[key] = ud -- result와 무관, 그대로 기록 - -- (Detach 재사용은 여기 담긴 { old = ... }가 담당) newKeyIndex[key] = pos end + + -- [재설계, 2026-08-21] 소멸 루프 — 조용히 파괴하지 않고 **처분을 묻는다**. + -- 아래 "`KeyGone`" 절이 소스. for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key if not seen[key] then - local prev = mounted[key] - if prev ~= nil then rawRemove(self, prev) end -- [재정정, 2026-08-18] 파괴 - mounted[key], userdata[key] = nil, nil + local prev = mounted[key] or self._detached[key] + local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key]) + if result == None then result = nil end + local detach = (result == Detach) + if detach then result = nil end + if result ~= nil and result == prev then + error("Slot:List — KeyGone에 prev를 그대로 반환할 수 없음(키가 없는데 마운트 유지는 모순). " + .. "계속 들고 있으려면 Detach를 반환할 것") + end + settle(key, result, detach, 0) -- pos는 의미 없음(자리를 안 차지함) + userdata[key] = ud -- 유저가 nil을 반환해야 지워짐 end end - keyIndex = newKeyIndex + keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다** end local data = self._listData @@ -1229,8 +1277,10 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도 같이 GC됨(아래 "구독 시점" 절). -- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawRemove`/ - `rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째 +- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawMove`와, 처분 헬퍼 + `releaseElement`(→ `rawRemove` 또는 `rawUnmount`)/`rawDetach`** + (**[2026-08-21]** `Detach` 확정으로 `rawDetach`가, `Owned` 확정으로 + `releaseElement`가 추가됨 — 처분 분기가 한 군데(`settle`)로 모였다) (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째 세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을 **부분적으로 되돌림** — `nil` 리턴/키 소멸은 다시 **파괴**가 기본이고, 값 교체와 `Detach`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이 @@ -1262,40 +1312,114 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 | updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 | |---|---|---| -| 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state` 교체와 동형이고, `Slot { State }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State` 교체" 절의 확정도 그대로 유지됨 | -| `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | -| `Detach` | **언마운트만** + 재사용 대기 | 아래 | -| 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) | +| 새 값(`result ~= nil`, `prev`와 다름) | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 만든 걸 `updateFn`이 자기 손으로 못 지운다(reconcile 중이라 `dispose`가 거부됨) — 지울 방법이 없으므로 reconcile이 대신 지운다. **[재정정, 2026-08-21]** 옛 서술의 "언마운트만"은 `state` 케이스를 이 표에 섞은 것이었고, 그건 이제 `Owned = false`가 담당(위 "`Owned` 옵션" 절) | +| `nil` / `None` | **파괴**(`Owned = false`면 언마운트만) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | +| `Detach` | **언마운트만 + `slot._detached`가 계속 보유** | 아래. 이미 detach 상태면 **nop** | +| `prev`를 그대로 반환 | 마운트 중이면 위치만 이동, **detach 중이면 재마운트** | 아래 | +| 키가 데이터에서 사라짐 | **`updateFn`에게 `KeyGone`으로 처분을 묻는다** | **[재설계, 2026-08-21]** 아래 "`KeyGone`" 절 | **`Detach` — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.** `filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번 -파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`Detach`와 함께 userdata를 -반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다: +파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`Detach`를 반환**하면 그 +자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다: ```lua -- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다 -return Detach, { old = prev, source = ... } +return Detach ``` -- **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐 - `userdata[key]`는 그대로 기록하므로(위 의사코드), 반환한 테이블 안의 - `old`가 그 요소를 강하게 붙잡아 GC를 막는다. 다음 사이클에 `updateFn`이 - 같은 `userdata`를 다섯 번째 인자로 다시 받으므로, 거기서 `old`를 꺼내 - 그대로 반환하면 **재마운트**된다(`rawAdd` 경로). -- **userdata는 그 키가 데이터에 남아 있는 한, 명시적으로 `nil`을 반환하기 - 전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다. -- **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서 - 발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를 - **둘 다** 지우는데, `Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미 - `nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는 - **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이 - 된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고 - 못박은 것과도, 위 "버릴 시점은 `updateFn`이 정한다"와도 어긋난다. - **세 선택지 중 하나를 M8 착수 전에 정할 것**(`question.md` 3번): - (a) 소멸 루프가 `userdata[key].old`도 확인해 `rawRemove`로 파괴, - (b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침), - (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다. - **지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지. +**[정정, 2026-08-21] 여기에 userdata를 딸려 보낼 필요는 없다.** 옛 서술은 +`return Detach, { old = prev }`처럼 사용자가 직접 붙잡는 모양이었으나, 보존 +주체가 `slot._detached`로 바뀌면서 다음 사이클에 그 요소가 **`prev`로 그대로 +전달**된다 — 그냥 `return prev`하면 재마운트된다. 바로 아래 절. + +#### ⭐ Detach된 요소는 `slot._detached`가 보유한다 — `userdata`가 아님 (2026-08-21 확정) + +**[전면 정정]** 옛 서술은 *"보존 주체는 `userdata`"* 였다 — `updateFn`이 +`return Detach, { old = prev }`처럼 자기 `ud`에 담아 붙잡고, 다음 사이클에 +그걸 꺼내 반환하면 재마운트된다는 모양. **이건 최종 처분이 불가능해서 +폐기**한다(사용자 확정: *"Detach 요소는 slot 안에 보관하는게 내 생각이였어서 +… ud 에 넣는거로는 최종 처분이 불가하다에 동의함"*). + +**왜 `userdata`로는 안 되나 — 세 가지**: + +1. **`:List`가 안을 못 들여다본다.** `userdata`는 계약상 완전히 opaque다 + (아래 "`userdata`의 생명주기 제약" 절) — 파괴할 시점이 와도 **뭘 죽여야 + 하는지 알 방법이 없다.** +2. **소유권이 붕 뜬다.** detach된 요소는 여전히 이 Slot이 관리 중이어야 + 남이 못 가져간다(`elementOwner`). `ud`에만 있으면 Slot 쪽엔 아무 기록이 + 없다. +3. **파괴 walk가 안 닿는다.** `destroySlotTree`는 `_elements`를 훑는데 + detach된 요소는 거기서 이미 빠졌다 — `ud` 안은 walk 대상이 아니다. + +**그리고 이건 "GC가 언젠가 치우겠지"로 넘어갈 수 없다.** quad는 자기가 만든 +Instance마다 gcconn을 걸고 **그 클로저가 `inst`를 캡처**하므로("참조를 놓는 +것만으로는 회수되지 않고 반드시 `Destroy`로 회수된다", +`base/lifecycle-pattern.md`의 "(0)" 절), `Parent = nil`인 detach 노드는 +**아무도 안 들고 있어도 자기 시그널 커넥션이 자기를 살려 영원히 남는다.** +GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다. + +**확정된 형태**: + +- **`slot._detached: {[key]: T}` — Slot 필드.** 클로저 업밸류가 아니라 + 필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다. (`mounted`/`userdata`/ + `keyIndex`가 `activateList`의 업밸류로 남는 것과 다른 이유 — + **마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.) +- **소유권은 유지된다** — `Detach`는 `rawUnmount`(소유권 반납)가 아니라 + `rawDetach`(**소유권 유지**)를 쓴다. 아래 "raw 3형제" 참고. +- **`prev`로 그대로 돌려준다** — reconcile이 `mounted[key] or self._detached[key]`를 + `prev`로 넘기므로, **`updateFn`이 `ud`로 붙잡을 필요가 없다.** 그대로 + 반환하면 재마운트된다. +- **이미 detach인데 또 `Detach`를 반환하면 nop**(사용자 확정) — 상태가 + 그대로 유지될 뿐 아무 일도 안 일어난다. 매 사이클 filter에 걸리는 흔한 + 경로라 여기서 뭔가를 반복하면 안 된다. +- **`ud`에 담는 것 자체는 여전히 자유** — 다만 **보존을 위해 담을 필요가 + 없어졌고**, 담더라도 그건 사용자 몫이라 `:List`가 정리해주지 않는다 + (아래 "`userdata`의 생명주기 제약" 절). + +```lua +-- filter에서 걸러진 아이템 — 그냥 Detach만 반환하면 된다 +if not shouldShow(item) then + return Detach, ud -- slot._detached가 붙잡아둠, ud로 홀드할 필요 없음 +end +-- 다시 보여야 하면 prev를 그대로 반환 → 재마운트 +if prev then return prev, ud end +``` + +#### `KeyGone` — 키가 데이터에서 사라질 때 처분을 묻는다 (2026-08-21 신설) + +**옛 갭**: 소멸 루프가 `mounted[key]`/`userdata[key]`를 조용히 지웠는데, +`Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미 `nil`이라 파괴 대상이 +아니었다 — **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겼다** +(그리고 위 gcconn 때문에 GC도 안 됐다). 사용자 판정으로 **`updateFn`에게 +한 번 더 물어보는 것**으로 해소: + +```lua +updateFn(item: T | KeyGone, index, offset, prev, ud) +``` + +- **`KeyGone`은 `Detach`/`None`과 같은 급의 sentinel**이고, 공개 표면 위치도 + 같다(패키지 최상위 export, 정의는 Slot 관련 파일 옆 — 아래 `Detach`의 + "공개 표면 위치 확정" 항목과 동일한 근거). +- **`updateFn`은 `if item == KeyGone then ... end`로 분기**해 처분을 고른다 — + 반환값 의미는 정상 사이클과 **완전히 같다**(`nil`=파괴, `Detach`=계속 홀드, + 새 값=교체). 그래서 reconcile의 처분 로직(`settle`)이 한 벌로 공유된다. +- **`prev`를 그대로 반환하는 것만 `error`** — 키가 없는데 마운트를 유지하라는 + 건 모순이다. 계속 들고 있으려면 `Detach`를 반환해야 한다. (다른 CRUD 에러 + 조건들과 같은 fail-fast 톤.) +- **`index`는 `0`** — 사라진 키는 자리를 안 차지한다. `offset`/`sum`이 이미 + 0-based 개수라 "아무 자리도 없음"이 `0`으로 자연스럽고, `index: number`라는 + 시그니처도 안 바뀐다(`nil`을 넣으면 타입이 바뀜). `offset`은 Slot의 것을 + 그대로 넘긴다(항상 유효). +- **한 번만 묻는다 — 새 규칙이 필요 없다.** 소멸 루프는 **직전 사이클의 + `keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번 + 사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된 + 것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시 + 나타나면 `prev`로 부활하고, (b) owner가 죽으면 아래 `Effect`가 정리한다. +- **`userdata`도 유저가 정한다** — `updateFn`이 `nil`을 반환해야 지워진다. + 키가 이미 사라졌으므로 다시 물어볼 기회가 없다는 뜻이지만, **owner가 죽으면 + 전부 같이 사라지므로 영구 누수는 아니다**(사용자 확정: *"대신에 slot 의 + 소유주가 죽으면 같이 죽는다. ud 도 모두 정리된다"*). - **이름 확정 — `Detach`(2026-08-19).** 처음엔 가칭 `PopOnly`로 도입됐고 사용자가 *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 생각해보아야함"*이라고 남겨 `question.md` 용어 정리 항목에 올라가 @@ -1364,12 +1488,11 @@ Slot:Single(state, updateFn?, opts?) - **⚠️ 이름은 `Owned`로 잠정** — `elementOwner`/`claimOwner`/`releaseOwner`와 같은 뿌리라 골랐다. 다른 가칭들과 함께 용어 정리 대기열(`question.md` 1번). -**⚠️ 아직 안 닫힌 짝 항목** — `Detach`로 홀드된 요소를 **어디에 보관하고 -언제 파괴하는지**(`slot._detached` 필드 + owner 죽을 때 `Effect`로 정리)와 -`KeyGone` 센티널은 별개로 확인 대기 중이다. -`qa-request/pre-implementation-qa-round4-followup.md`의 `F-3` 절이 소스 — -**그게 닫히기 전엔 이 절의 "파괴" 칸을 구현하지 말 것**(무엇을 파괴 대상으로 -훑을지가 거기서 정해짐). +**[2026-08-21 짝 항목도 닫힘]** `Detach`로 홀드된 요소의 보관 위치 +(`slot._detached` 필드)와 최종 처분(owner 죽을 때 `Effect`), `KeyGone` +센티널까지 같은 라운드에 확정됐다 — 아래 "Detach 요소는 `slot._detached`가 +보유한다"/"`KeyGone`" 절이 소스. `destroySlotTree`가 `_owned == false`면 +파괴 대신 언마운트로 빠지는 것도 그 확정의 일부. - **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가 이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가 @@ -1620,97 +1743,118 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 `qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절. ```lua --- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합 -local function attachSlot(slot, physicalTarget, ownerKey, position) - -- [정정, 2026-08-13 세션] bindLifetime 호출은 여기 없음 — top-level 전용 - -- 앵커링은 SlotHandler.process(아래)로 이동. attachSlot 자신은 이제 - -- top-level/nested 어느 깊이에서 불려도 완전히 동일하게 동작하는 순수 구조적 - -- mount 로직만 담당(레이어 구분은 오직 이 함수를 부르는 쪽의 책임) — retract - -- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은 - -- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에 - -- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음. +-- quad-base, Slot.luau +-- [전면 재작성, 2026-08-21 구현 전 QA 4라운드 확정] 옛 단일 `attachSlot`을 +-- **비공개 재귀 둘 + 얇은 공개 진입점**으로 분해. 공개 표면(이름/시그니처/ +-- 호출부 셋)은 하나도 안 바뀐다 — 쪼갠 건 함수가 아니라 **재귀**다. +-- 근거와 대안 비교는 `research/slot-attach-decomposition.md`. +-- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다. +local function materializeSlotTree(slot, physicalTarget, ownerKey, position) + -- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1) local offsetSource = Source(0) - Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산 + Dispatch.setOffsetSource(ownerKey, position, offsetSource) slot.Offset = offsetSource - -- [재정정, 2026-08-18 구현 전 QA 3라운드, `RC-3`/`RC-4` 해결 — - -- 사용자 설계] `_mounted`는 여기서 아직 세팅하지 않는다 — 그래야 - -- 아래 `activateList`가 실행되는 동안 `self._mounted`가 계속 - -- `false`라, `:List`의 reconcile이 부르는 `rawAdd`가 "아직 마운트 - -- 전"(= `_elements`에만 넣고 끝) 경로를 타서 이 시점엔 물리 - -- 마운트도 Dispatch 등록도 전혀 안 일어난다. 옛 코드는 `_mounted`를 - -- 맨 위에서 세팅해뒀었는데, 그러면 reconcile의 `rawAdd`가 매 항목마다 - -- 즉시 물리 마운트 + `Dispatch.setLength`를 태워(아래 flush 루프가 - -- 곧 다시 처리할 바로 그 자리를) 두 가지 문제를 냈다 — (a) 아직 - -- Blocker가 없어(그건 flush 루프 직전에야 생김) 매 항목마다 게이팅 - -- 없이 `recompute`가 돎(`RC-3`), (b) nested Slot 항목은 이 시점에 - -- 이미 `attachSlot`이 한 번 불렸는데, 아래 flush 루프가 같은 요소를 - -- 다시 순회하며 `attachSlot`을 **또** 불러 이중 실행됨(`RC-4`). - -- `_mounted`를 `activateList` 뒤로 미루면 이 함수 안에서 실제 - -- 마운트가 일어나는 자리는 아래 flush 루프 단 하나로 통일된다 — - -- `:List`든 수동 CRUD든 구분할 필요가 없어짐. 상세 트레이싱은 - -- `qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절. - if slot._listed then - activateList(slot, physicalTarget) -- reconcile이 채우는 건 `_elements`뿐 — 물리 마운트는 안 함(위 참고) - end + -- `_mounted`는 여전히 false — reconcile의 rawAdd가 "아직 마운트 전" + -- 경로(= `_elements`에만 넣고 끝)를 타야 함(RC-3/RC-4). 이제 이 조건이 + -- **함수 경계로 강제**된다: `_mounted`를 켜는 코드가 이 함수엔 아예 없음. + if slot._listed then activateList(slot, physicalTarget) end - slot._mounted = true - slot._mountedInst = physicalTarget - - -- **[정정, 2026-08-18 3라운드]** `slot.Length`는 이 시점에 아직 - -- "확정된 값"이 아니다 — 최종 값은 아래 flush 루프 끝의 `recompute`가 - -- 매긴다. 여기서 넘기는 건 값이 아니라 **State 객체 자신**이라 무해함: - -- 부모는 이 객체를 구독해뒀다가, 그 값이 나중에(flush 끝나고) 바뀌면 - -- 정상적으로 다시 반응한다(부모 배치가 아직 안 끝났으면 부모 자신의 - -- Blocker가 그 반응을 알아서 미룸 — 아래 "확인만 하고 새 결함 없음" - -- 절 참고). 옛 주석("확정된 값으로 등록")은 옛 순서(`_mounted`가 - -- `activateList`보다 먼저라 그 안에서 이미 최종화되던 것) 기준이었고 - -- 이제는 안 맞아 정정. - Dispatch.setLength(ownerKey, position, slot.Length) - - -- attach 전에 이미 들어와있던 요소들(수동 CRUD로 마운트 전 `:Add()`된 - -- 것) **및** 방금 `activateList`가 `_elements`에만 채워둔 `:List` - -- 결과물 — 이제 이 flush 루프가 어느 경로로 왔든 상관없이 유일한 - -- 물리 마운트 지점이다. `slot._elements`의 개수(N)가 이미 정해진 채 - -- position을 하나씩 등록하는 배치라 `Dispatch.drive`와 같은 크래시 - -- 위험이 있음(`RC-1`) — 이 Slot 자신의 owner 키로 별도 Blocker를 새로 - -- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의 - -- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용. + -- 자식 부기만, 재귀로 길이를 bottom-up 확정. + -- Blocker가 감싸는 게 이제 **등록뿐**이라 "배치 등록 게이팅"이라는 + -- 정의와 범위가 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음). local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용 blocker:On() for i, element in ipairs(slot._elements) do if isSlot(element) then - attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신 + -- 재귀 — 자식이 자기 끝에서 setLength(slot, i, 자기Length)까지 하고 옴 + materializeSlotTree(element, physicalTarget, slot, i) else - -- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도 - -- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1. + -- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None, + -- length는 상수 1. 순서는 늘 offsetSource → setLength(C4). Dispatch.setOffsetSource(slot, i, None) Dispatch.setLength(slot, i, 1) - element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행 end end blocker:OffWithoutEmit() local bk = getBookkeeping(slot) - if bk then recompute(slot, bk) end -- 여기서 slot.Length가 비로소 진짜 값으로 확정됨 + if bk then recompute(slot, bk) end -- 여기서 slot.Length가 최종값으로 확정 + + -- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에 + -- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에 + -- 만족시킬 수 없었던 지점. + Dispatch.setLength(ownerKey, position, slot.Length) +end + +-- (2) 물리만 붙인다. 부기를 단 한 줄도 안 건드린다 → Blocker 불필요. +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) + + for i, element in ipairs(slot._elements) do + if isSlot(element) then mountSlotTree(element, physicalTarget) + else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행 + end +end + +-- (3) 공개 진입점 — 이름/시그니처/호출부 전부 옛것 그대로. 몸통만 두 줄. +local function attachSlot(slot, physicalTarget, ownerKey, position) + materializeSlotTree(slot, physicalTarget, ownerKey, position) + mountSlotTree(slot, physicalTarget) end ``` -**⚠️ [신설, 2026-08-18 3라운드 감사 후속] 좁은 엣지 케이스 — 배치 밖에서 -이 Slot이 단독으로 (재)마운트되면, 부모의 `recompute`가 아직 안 굳은 -`slot.Length`로 한 번 헛돌 수 있다.** `Dispatch.setLength(ownerKey, -position, slot.Length)`(위 코드)는 `slot.Length`가 `State`라 등록 즉시 -1회 실행을 동기로 태우는데, 이 `attachSlot` 호출이 `Dispatch.drive`의 -배치나 부모 Slot의 flush 루프 **안**이면 부모 Blocker가 아직 켜져 있어 -안전하게 스킵되지만, **배치 밖**(예: `state` 값이 steady state에서 -반응형으로 교체될 때, 부모 owner의 Blocker는 이미 꺼진 채)이면 부모의 -`gatedRecompute`가 즉시 실행돼 아직 flush가 안 끝난 `slot.Length`로 한 -번 계산한다 — flush가 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면 -정확한 값으로 자기 교정된다. 크래시도 영구적으로 틀린 값도 아니고 -최악의 경우 한 프레임짜리 낭비 재계산 — 손대지 않기로 함, 다만 이 -자리를 다시 만질 때 놓치지 않도록 기록. 트레이싱 원문은 -`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함 -없음" 절. +**왜 쪼갰나 — 이득 넷**(상세와 기각된 대안은 +`research/slot-attach-decomposition.md`): + +1. **C6와 C7이 처음으로 동시에 만족된다.** 옛 코드는 `setLength`의 자리가 + 하나뿐이라 "최종값으로 등록"(C6)과 "부기가 물리보다 먼저"(C7) 중 하나를 + 포기해야 했다 — C7을 지키고 `Length = 0`으로 등록한 뒤 자기 교정하는 + 쪽이었다. 분해하면 둘 다 성립하고, **배치 밖 재마운트의 부모 `recompute`가 + 2회 → 1회**로 준다(아래 "해소" 참고). +2. **순서 제약이 줄 순서가 아니라 함수 경계로 강제된다.** `RC-1`/`RC-3`/`RC-4`가 + 전부 "한 함수 안에서 줄 순서를 잘못 잡아" 난 버그였는데, `_mounted`를 켜는 + 코드가 `materializeSlotTree`엔 아예 없으므로 그 실수 클래스가 구조적으로 + 사라진다. **사용자 확정 근거**: *"이게 하나의 큰 복잡한 복합 함수라 여러 + session 간의 실수가 발생하던 부분이고 … 지금 의사코드를 건들이는 비용이, + 추후 실수가 누적되는 비용보다 싸다고 생각함."* +3. **`_mounted`의 의미가 정직해진다** — 옛 코드에선 "`activateList`는 지났고 + flush는 아직"이라는 중간 시점이었는데, 이제 문자 그대로 "mount 단계를 + 지났는가"다. +4. **Blocker의 범위가 정의와 일치하고, 백엔드에 seam이 생긴다** — + `mountSlotTree`가 부기를 안 건드리는 순수 walk라, 일괄 삽입이 유리한 + 백엔드(웹 `DocumentFragment` 등)가 **이 함수 하나만** 갈아끼울 수 있다. + +**⚠️ 바뀌는 관측 가능한 동작 하나 — 물리 마운트가 "부기 완료 후 일괄"이 된다.** +`Parent` 대입 **순서 자체는 동일**(둘 다 깊이 우선 같은 순서)하지만, 옛 +코드는 부기와 물리가 인터리브됐고 지금은 부기가 전부 끝난 뒤 물리가 몰린다. +`Parent` 대입은 `ChildAdded`/`DescendantAdded`를 **동기 발화**시키므로 사용자 +핸들러가 이 차이를 관측할 수 있는데, **분해 쪽이 더 정확하다** — 첫 +`ChildAdded`가 뜰 때 서브트리 전체의 `Length`/`Offset`이 이미 최종값이다(옛 +코드는 `inner.Length == 0`인 미완성 스냅샷을 보여줬다). `slot.Length` +구독자도 `0` → 최종 두 번이 아니라 최종값으로 한 번 발화한다. + +**✅ [해소, 2026-08-21] 옛 "좁은 엣지 케이스"는 이 분해로 사라졌다.** +여기 있던 ⚠️ 항목(배치 밖 단독 재마운트 시 부모 `recompute`가 아직 안 굳은 +`slot.Length`로 한 번 헛도는 것)은 `setLength`가 `materializeSlotTree` 끝으로 +가면서 **처음부터 최종값**이 되어 발생 경로가 없어졌다. 트레이싱 원문은 +`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함 없음" 절. **최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:** ```lua @@ -1726,7 +1870,7 @@ if isSlot(element) and self._mounted then attachSlot(element, self._mountedInst, self, index) end -- self가 아직 마운트 전이면 _elements에만 들어가고, self가 나중에 --- attachSlot될 때 위 flush 루프가 처리 +-- attachSlot될 때 materializeSlotTree/mountSlotTree의 루프가 처리 ``` **이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인, @@ -1772,6 +1916,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 + -- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고, + -- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유). slot._mounted, slot._mountedInst = false, nil -- [정정, 2026-08-20 `SL-75`] slot.Offset은 건드리지 않는다 — nil로 되돌리면 -- 그 Source를 이미 구독 중인 다운스트림이 영구히 끊긴다(포탈이 깨짐). @@ -1781,6 +1935,12 @@ local function unmountSlotTree(slot) end local function destroySlotTree(slot) + -- [2026-08-21] Owned=false면 이 Slot은 요소를 만든 적이 없다 — 파괴 대신 + -- 언마운트만(위 "`Owned` 옵션" 절). `Slot:Add(state)` sugar가 그 경우. + if slot._owned == false then + unmountSlotTree(slot) + return + end for i, element in ipairs(slot._elements) do -- [재정정, 2026-08-20 구현 전 QA 4라운드 `C-4`] 여기서 releaseOwner를 -- 명시적으로 부르지 **않는다** — 2026-08-13 감사가 넣었던 것을 되돌림. @@ -1791,6 +1951,17 @@ local function destroySlotTree(slot) element:Destroy() end end + -- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`에 + -- 없으므로(rawDetach가 뺐음) 위 루프가 못 닿는다. **`dispose`가 재귀적으로 + -- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절). + for key, element in pairs(slot._detached) do + if isSlot(element) then destroySlotTree(element) else element:Destroy() end + slot._detached[key] = nil + end + if slot._detachCleanup then + unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음 + slot._detachCleanup = nil + end local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들 if bk then for i, observer in pairs(bk.observers) do @@ -1830,6 +2001,41 @@ function rawUnmount(self, index) recompute(self, bk) end +-- [신설, 2026-08-21] rawUnmount의 "소유권 유지" 짝 — `Detach`가 쓴다. +-- rawUnmount와 **딱 하나만 다름: releaseOwner를 안 부른다.** +-- detach된 요소는 여전히 이 Slot이 들고 있으므로(slot._detached) 소유권을 +-- 놓으면 남이 가져갈 수 있게 되어 "다중 마운트 금지" 불변식이 깨진다. +function rawDetach(self, index) + local element = self._elements[index] + local bk = getBookkeeping(self) + if bk.observers[index] then + unbindLifetime(bk.observers[index]) + end + -- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이 + if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end + + spliceArraysDown(self, index) + recompute(self, bk) +end + +-- [신설, 2026-08-21] 밀려나거나 지워지는 요소의 처분 — `Owned`가 정한다 +-- (위 "`Owned` 옵션" 절). detach 중이던 요소는 이미 `_elements`에 없으므로 +-- spliceArraysDown/recompute가 필요 없어 경로가 갈린다. +function releaseElement(self, element, wasDetached) + if wasDetached then + if self._owned ~= false then + if isSlot(element) then destroySlotTree(element) else element:Destroy() end + end + return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음 + end + if self._owned ~= false then rawRemove(self, element) -- 파괴 + else rawUnmount(self, element) end -- 언마운트만(사용자 것) +end + +-- **raw 3형제 — 갈리는 축이 둘(파괴하는가 / 소유권을 놓는가)**: +-- rawRemove : 소유권 반납 + **파괴** +-- rawUnmount: 소유권 반납 + 파괴 안 함 ← 요소를 살려서 내보내는 경로 +-- rawDetach : **소유권 유지** + 파괴 안 함 ← 내가 계속 들고 있는 경로 function rawRemove(self, index) local element = self._elements[index] local bk = getBookkeeping(self) @@ -2131,6 +2337,13 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 요구되고 있으면 파괴를 거부하고 즉시 `error`.** 떼어내주지 않음 — 떼어내는 건 `Set`(언마운트)의 몫이고, `dispose`는 그 뒤에 부르는 것. 아무도 요구하지 않는 상태면 실제로 파괴(Slot이면 하위까지 재귀). + **[확인 완료, 2026-08-21] Slot-in-Slot에서도 재귀가 성립한다** — 파괴 walk는 + `destroySlotTree`이고 그게 `_elements`를 훑다 `isSlot(element)`면 재귀하며, + **`_detached`(Detach로 홀드 중인 것)까지 같은 함수가 훑는다**(위 코드). + `_owned == false`면 파괴 대신 언마운트로 빠지는 것도 그 안에 있다. 즉 + `dispose(slot)` 한 번으로 서브트리 전체 + 홀드분 전체가 정리된다 — + **남는 예외는 `userdata` 안에 사용자가 직접 넣어둔 것뿐**이고, 그건 + 위 "`userdata`의 생명주기 제약" 절대로 사용자 책임이다. - **왜 거부가 맞는가(사용자)**: "실제로 클리어 하거나 Destroy 해도 로블록스엔 에러 안 나는데, quad에선 데이터 구조가 깨지는 일이니까요." 엔진은 조용히 넘어가지만 quad의 `_elements`/`lengthList`/`sourceList`/ diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md index 7ff5ec3..2f51f7a 100644 --- a/.claude/qa-request/pre-implementation-qa-round4-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -1,8 +1,13 @@ # 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문 -**상태**: **[2026-08-21] 3차 처리 완료 — 아래 G절이 최신.** +**상태**: **[2026-08-21] 4차 처리로 종결 — 아래 H절이 최신이자 마지막.** +`F-3`이 전량 확인됐고 `attachSlot` 분해도 확정돼 `base/`에 전부 반영됐다. +**이 followup에 열린 질문은 남아있지 않다.** 5라운드 문항지는 만들지 +않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록. + +**[2026-08-21] 3차 처리 — 아래 G절.** `F-4`는 전부 닫혔고(`F-4-3`은 `research/slot-attach-decomposition.md`로 넘어감), -**남은 건 `F-3`의 (1)~(3)과 "+"뿐**(요약은 `G-3`). +그 시점에 남았던 건 `F-3`의 (1)~(3)과 "+"였다(요약은 `G-3`, H절에서 닫힘). **[2026-08-21] 2차 처리 — 아래 F절.** B절은 전부 확인됐고 C절 결정도 대부분 반영됐다. **지금 열려 있는 건 F절의 @@ -1069,3 +1074,104 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = → **여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/"+"가 한 번에 닫히고, 그때 `base/` 반영과 `attachSlot` 분해 논의를 이어서 하면 된다.** + +--- + +# H절 — 4차 처리 (2026-08-21): `F-3` 전량 확인 + 함수 분해 확정, `base/` 반영 완료 + +**입력**: 사용자 회신 — "Detach 요소는 slot 안에 보관하는게 내 생각이였어서 +(2) 제안에 동의. ud 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach +인데 또 detach 를 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 +사용하게 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘. +그런데 함수 분해는 확정해도 좋을것 같음. 이게 하나의 큰 복잡한 복합 함수라 +여러 session 간의 실수가 발생하던 부분이고, 지금 적절한 방향으로 이동하지 +않으면 계속 실수에 의한 시간/기술비용이 축적될것 같음. 지금 의사코드를 +건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함." + +이걸로 **`G-3`의 `F-3` (1)~(5)와 `G-2`의 분해 결정이 한 번에 닫혔다.** +아래는 실제로 `base/`에 들어간 것. + +## H-1. `Detach` 보존 주체 — `userdata` → `slot._detached` (확정·반영) + +- `base/slot-plan.md`에 **"Detach된 요소는 `slot._detached`가 보유한다"** 절 + 신설. 옛 서술(보존은 반환한 `userdata`가 담당)은 그 자리에서 정정. +- **raw 3형제로 분화** — `rawRemove`(소유권 해제 + 파괴) / + `rawUnmount`(소유권 해제 + 파괴 안 함, 요소가 떠남) / + **`rawDetach`(소유권 **유지** + 파괴 안 함, 내가 계속 들고 있음)**. + `Detach` 경로는 `rawDetach`를 쓴다 — 소유권을 놓으면 `destroySlotTree`가 + 못 줍는다. +- **재-`Detach`는 nop**(이미 `_detached`에 있으면 아무것도 안 함), + **`prev`를 그대로 반환하면 재마운트**(`_detached`에서 빼고 `rawAdd`). + 둘 다 `settle()` 안에서 `wasDetached` 분기로 처리 — 정상 사이클과 소멸 + 루프가 같은 함수를 공유하므로 두 경로가 갈라질 수 없다. +- `releaseElement(self, element, wasDetached)` — detached 상태에서 처분될 + 땐 이미 소유권을 들고 있으므로 `rawRemove`가 아니라 바로 파괴 + (`_owned ~= false`일 때). + +## H-2. `KeyGone` 센티널 (확정·반영) + +- 키가 데이터에서 사라진 자리는 조용히 처분하지 않고 + **`updateFn(KeyGone, 0, offset, prev, userdata[key])`로 한 번 더 묻는다.** + 반환은 정상 사이클과 같은 `settle()`로 흘린다. +- **`index`는 `0`**(자리가 없어졌으므로 유효한 인덱스가 없음), + **`KeyGone`에 `prev`를 그대로 반환하면 `error`**(자리 없는 요소를 유지할 + 방법이 없음) — `G-3`의 추천 그대로 확정. +- **다시 안 묻기는 자동 성립** — 소멸 루프가 이전 `keyIndex`만 돌기 때문. +- owner가 죽을 때의 최종 처분은 `mountSlotTree`가 거는 `Effect`의 cleanup이 + `slot._detached`를 전부 비우는 것으로 담당(`_owned == false`면 파괴 안 함). + +## H-3. `Owned` 옵션 (확정·반영) + +`Detach`(사이클 단위, "내 건데 잠깐 빼둠")와 `Owned`(설치 단위, "애초에 내 +게 아님")는 **직교하는 축**이라는 정리가 여기서 확정됐다. `:List`/`:Single`의 +설치 시점 플래그(기본 `true`)이고, `false`면 어떤 경로로도 파괴하지 않고 +언마운트만 한다 — `state`처럼 사용자가 만들어 넘긴 요소용. +`C-2`의 "`:List`의 값 교체 파괴가 `state` 의미론과 충돌"이 이걸로 +닫힌다(값 교체는 `Owned = true`면 파괴가 맞다 — `updateFn`이 만든 걸 자기 +손으로 못 지우면 새는 쪽이 된다). + +## H-4. `attachSlot` 분해 (확정·반영) + +`research/slot-attach-decomposition.md`를 **확정**으로 승격하고 의사코드를 +`base/slot-plan.md`에 반영했다. 결론은 후보 **(B)**: + +- **`materializeSlotTree(slot, physicalTarget, ownerKey, position)`** — 부기만. + `Offset` 설치 → `activateList`(`_mounted`는 아직 `false`) → Blocker `On` + → 자식 재귀/`setLength` → `OffWithoutEmit` → `recompute` → + **마지막에 `setLength(ownerKey, position, slot.Length)`**. +- **`mountSlotTree(slot, physicalTarget)`** — 물리 `Parent` 대입과 + `_mounted = true`, `_detachCleanup` Effect 설치만. Blocker 불필요. +- **공개 `attachSlot`은 그 둘을 순서대로 부르는 두 줄** — 이름/시그니처/호출부 + 전부 그대로라 다른 문서의 참조가 안 깨진다. + +이걸로 **C6("부모에게 미는 길이는 최종값이어야 한다")와 C7("부기가 물리보다 +먼저 끝난다")가 처음으로 동시에 만족된다** — 한 함수 안에서는 +`setLength` 슬롯이 하나뿐이라 원리적으로 불가능했던 조합이다. 부수로 배치 +밖 재마운트의 부모 `recompute`가 2회 → 1회. + +**사용자가 우려한 관측 가능한 차이**("일자 진행 vs 관측 이후 일괄 등록")는 +**`Parent` 대입 순서 자체는 안 바뀌고**, `ChildAdded` 핸들러가 볼 때 서브트리 +부기가 이미 최종값이라는 점만 바뀐다 — 옛 코드는 미완성 스냅샷을 보여줬으므로 +**엄밀히 더 정확해지는 방향**이다. 분석 원문은 그 문서의 7절. + +## H-5. `SL-38` userdata 제약 보강 (반영) + +`userdata`에 "GC만으로 정리되는 값만" 담으라는 제약의 예시에 **quad-제작 +Instance**를 추가했다 — gcconn 트릭 때문에 참조를 놓아도 회수되지 않아 +`Destroy` 없이는 영구 누수다. 기존 예시가 `:Subscribe()` Observer뿐이라 +Instance는 안전해 보였다. + +## H-6. `ROADMAP.md` 정합 (반영) + +M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠️ 미결)를 +그대로 서술하고 있어 정정했고, Slot-in-Slot 항목에 분해 결과를 반영했다. +"값 교체와 `Detach` 경로만 파괴 안 함"도 `Owned` 기준으로 재정정. + +## H-7. 남은 것 + +- **`question.md` 3번의 관련 항목**은 이 처리로 전부 닫혔다. +- 사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는 + 것으로 끝낼 수 있어보임"(D절 회신). +- 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`), + `table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는 + `luau-test/STATUS.md`. diff --git a/.claude/question.md b/.claude/question.md index 8164ad1..bd21959 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -164,18 +164,18 @@ `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전 필요**, `base/store-plan.md`의 "타입 추론 문제" 절. -- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던 - 요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면 - `mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가 - `userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고 - 참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은 - `updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`의 - `old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를 - 고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정, - 2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전 - 필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 - 기본" 절. -- **[신설, 2026-08-21 구현 전 QA 4라운드] `attachSlot` 책임 분해** — 한 +- **[해소, 2026-08-21] `Detach` 홀드 중 키가 사라졌을 때의 처분** — + 선택지 (c)로 확정: `updateFn`을 **`KeyGone`으로 한 번 더 불러 처분을 + 묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라 + **`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가 + 닿고 소유권도 유지됨), owner가 죽으면 `Effect`가 정리한다. 원래 갭이 + 치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가 + **GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`의 + "Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절. +- **[해소, 2026-08-21] `attachSlot` 책임 분해** — **(B) 분해 채택으로 확정**, + `base/slot-plan.md`에 반영 완료(`materializeSlotTree`/`mountSlotTree`/얇은 + `attachSlot`). 근거 기록은 `research/slot-attach-decomposition.md`. + 아래는 그 열려 있던 시점의 서술: 한 함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치 게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의 최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에 diff --git a/.claude/research/slot-attach-decomposition.md b/.claude/research/slot-attach-decomposition.md index a751e4e..5c1af7c 100644 --- a/.claude/research/slot-attach-decomposition.md +++ b/.claude/research/slot-attach-decomposition.md @@ -1,7 +1,18 @@ # `attachSlot` 책임 분해 — 확장 논의 준비 자료 -**상태**: research — **논의 전 준비 자료.** 아무것도 확정하지 않았고, 다음 -논의가 바로 시작될 수 있게 **지금 무엇이 얽혀 있는지**를 한 곳에 모은 것. +**상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`에 +반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한 +설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` / +`mountSlotTree` / 얇은 `attachSlot`)이 소스다. + +**사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게 +하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금 +적절한 방향으로 이동하지 않으면 계속 실수에 의한 시간/기술비용이 축적될것 +같음. 지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 +생각함."* + +아래는 그 결론에 이른 조사/논거를 그대로 보존한 것 — 원래 서술은 "논의 전 +준비 자료"였다. **왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가 @@ -9,8 +20,8 @@ flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자 attachSlot 되는게 맞을지도. **attachSlot 의 기능이 너무 다양해진게 문제같음.** 이 부분에 있어서는 확장 논의를 하게 준비해두자."* -정본은 여전히 `base/slot-plan.md`의 "재귀 메커니즘" 절 — -**이 문서가 그 확정을 대체하지 않는다.** +정본은 `base/slot-plan.md`의 "재귀 메커니즘" 절 — **이 문서가 그 확정을 +대체하지 않는다.** --- diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 19b7b05..073d540 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1609,3 +1609,47 @@ Luau 함정을 발견해 `typing-limits.md` §6으로 승격. (`type function`은 outer local 참조 불가, cross-package엔 `export type function` + 이중 꺾쇠 제네릭 인스턴스화 필요). 핸드오버 감사 2라운드로 구 시그니처 잔존/개수 하드코딩 8건 발견·수정 후 커밋. + +## 2026-08-19 — 구현 전 QA 4라운드 문항지 작성 (회신 대기) + +원문: `session/2026-08-19-04-qa-round4-questionnaire.md` + +사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 … +서브에이전트는 쓰지 말아줘")으로 `base/` 확정 주장 전체를 한 맥락에서 읽으며 +"예가 나와야 정상인 문항"으로 전수 문항화. **설계 결정도 정정도 하나도 안 +내리고** 문항지(`qa-request/pre-implementation-qa-round4.md`)만 남긴 채 회신 +대기로 끝난 세션. + +## 2026-08-19 — 핸드오버 준비, `session-summary.md`/`ROADMAP.md` stale 대청소 + +원문: `session/2026-08-19-09-handover-prep-roadmap-status-sync.md` + +`quad-roblox-types` 언급 확인 요청에서 시작했으나 훨씬 큰 공백 둘을 발견 — +이 색인에 당일 세션 5개(04~08)가 통째로 빠져 있었고, +`CLAUDE.md`/`project-context.md`/`ROADMAP.md`가 "구현 아직 시작 전"이라는 +낡은 전제를 깔고 있었다(실제로는 M0/M1이 이미 완료·커밋됨). 둘 다 즉시 +반영하고 감사 라운드로 재검증. + +## 2026-08-20 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합 + +원문: `session/2026-08-20-01-qa-round4-response-processing.md` + +업스트림 12커밋(pesde 전환, mise/selene, RunInit 재설계, quad-types/ +type-version-check 신설, M0 스파이크)을 먼저 rebase로 병합한 뒤, 사용자 +회신을 (a) 바로 반영 / (b) 설명 보강 후 재질문 / (c) 사용자 판단 필요 / +(d) 조사해서 답이 나옴으로 갈라 (a)만 `base/`에 반영. 나머지는 +`pre-implementation-qa-round4-followup.md`로 정리해 남김. + +## 2026-08-21 — `Detach` 보존 주체/`KeyGone`/`Owned` 확정, `attachSlot` 분해 + +원문: `session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md` + +QA 4라운드의 마지막 열린 항목이 닫힌 세션. gcconn 트릭 때문에 detach된 +quad-제작 Instance는 **GC 폴백이 없다**는 걸 근거로 보존 주체를 +`userdata` → **`slot._detached` 필드**로 뒤집고, 키 소멸 처분을 **`KeyGone` +센티널**로 확정(재-`Detach`는 nop, `prev` 반환은 재마운트). `Detach`(사이클 +단위)와 **`Owned`**(설치 단위)를 직교 축으로 분리해 `state` 의미론 +충돌도 해소. 같이 **`attachSlot`을 `materializeSlotTree`+`mountSlotTree`로 +분해** — "부모에게 미는 길이는 최종값"과 "부기가 물리보다 먼저"가 한 함수 +안에선 동시 만족 불가라는 진단이 근거였고, 사용자가 "지금 의사코드를 +건들이는 비용이 추후 실수가 누적되는 비용보다 싸다"로 확정. diff --git a/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md new file mode 100644 index 0000000..3f1b778 --- /dev/null +++ b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md @@ -0,0 +1,139 @@ +# 2026-08-21-01 — `Detach` 보존 주체 / `KeyGone` / `Owned` 확정, `attachSlot` 분해 + +**한 줄**: QA 4라운드 followup의 마지막 열린 항목(`F-3`)이 사용자 회신으로 +전량 닫히면서, `:List`의 요소 소유권 모델(`slot._detached` + `KeyGone` + +`Owned`)과 `attachSlot`의 책임 분해(`materializeSlotTree`/`mountSlotTree`)가 +확정돼 `base/slot-plan.md`에 반영됐다. + +**소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고, +처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.md`의 +H절, 분해 근거는 `research/slot-attach-decomposition.md`. 이 파일은 그 +결정들이 **어떤 논의를 거쳐 나왔는지**의 원문 기록이다. + +--- + +## 1. 발단 — `F-3`을 제안하게 된 관측 + +3차 처리(G절)까지 오면서 `Detach`(옛 `PopOnly`) 경로에 두 개의 구멍이 +남아 있었다. + +1. **보존 주체가 `userdata`였다.** 기존 문서는 `updateFn`이 + `Detach, { old = ..., source = ... }`를 반환하면 "보존은 반환한 userdata가 + 담당"한다고 확정해뒀다. +2. **키가 데이터에서 사라졌을 때의 처분이 미결**이었다(`question.md` 3번, + `ROADMAP.md` M6에 ⚠️로 박혀 있었음). + +2번을 파다가 1번이 함께 무너졌다. 결정적인 근거는 **gcconn 트릭**이다 — +`base/lifecycle-pattern.md`가 확정해둔 대로 quad가 만든 모든 Instance는 +`GetPropertyChangedSignal("ClassName")` 커넥션의 클로저가 자기 자신과 +`gchold`를 캡처하므로 **참조를 놓는 것만으로는 회수되지 않는다.** 즉 +detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어버리면 +**GC 폴백이 아예 없이 영구 누수**다. `userdata`는 `:List`에게 opaque라 +"이 안에 뭐가 들었으니 죽여라"를 알 수 없다. + +그래서 세 가지를 묶어 제안했다: (1) 보존은 Slot 필드가 한다, (2) owner가 +죽을 때의 정리는 `Effect`가 한다, (3) 키가 사라지면 `updateFn`에게 한 번 +더 묻는다. + +## 2. 사용자 회신 — 두 가지를 더 얹었다 + +> "Detach 요소는 slot 안에 보관하는게 내 생각이였어서 (2) 제안에 동의. ud +> 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach 인데 또 detach 를 +> 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 사용하게 +> 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘." + +여기서 **"이미 detach인데 또 detach → nop"**과 **"`prev`를 그대로 반환 → +재마운트"**가 확정됐다. 이 둘은 원래 제안에 없던 것으로, 보존 주체가 +`slot._detached`로 옮겨간 순간 **`ud`에 홀드할 필요 자체가 없어진다**는 +따름정리를 사용자가 먼저 짚은 것이다 — reconcile이 `prev`로 그대로 +돌려주니까 사용자 코드가 `old`를 `ud`에 담아 다니지 않아도 된다. + +## 3. 반영 — `settle()`이 두 경로를 강제로 합친다 + +구현상 가장 중요한 판단은 **정상 사이클과 소멸 루프가 같은 처분 함수를 +공유하게 만든 것**이다. `settle(key, result, detach, pos)` 하나가 +`wasMounted`/`wasDetached`를 함께 보고 분기한다: + +- `detach`면 → `rawDetach`(언마운트하되 **소유권 유지**), `_detached`로 이동. + 이미 `_detached`에 있으면 `wasMounted == nil`이라 자연히 nop. +- `result == prev`인데 `wasDetached`면 → `_detached`에서 빼고 `rawAdd` = 재마운트. +- `result == nil`이면 → `releaseElement(self, prev, wasDetached ~= nil)`. + +두 경로가 갈라질 수 없게 만든 이유는 명확하다. 이 코퍼스에서 반복적으로 +났던 버그(`RC-1`/`RC-3`/`RC-4`)가 전부 **"비슷한 두 경로 중 한쪽만 고쳤다"** +계열이었다. + +`rawRemove`(소유권 해제 + 파괴) / `rawUnmount`(소유권 해제 + 파괴 안 함) / +**`rawDetach`(소유권 유지 + 파괴 안 함)** 3형제로 분화한 것도 같은 이유 — +"파괴하는가"와 "소유권을 놓는가"가 원래 한 축에 뭉쳐 있었는데, `Detach`는 +정확히 **파괴 안 하면서 소유권은 유지**하는 조합이라 기존 둘로는 표현이 +안 됐다. + +## 4. `Owned` — `Detach`와 직교하는 두 번째 축 + +`C-2`(`:List`의 "밀려난 prev는 dispose"가 `state` 의미론과 충돌)를 +닫은 것이 이 축이다. 정리하면: + +- **`Detach`** = 사이클 단위. "내 건데 잠깐 빼둠." +- **`Owned`** = 설치 단위. "애초에 내 게 아님." + +이 둘이 직교한다는 걸 명시하고 나니 충돌이 사라졌다 — 값 교체 시 파괴는 +`Owned = true`일 때 **맞다**(그 요소는 `updateFn`이 만들었고, `:List`가 +안 지우면 아무도 못 지운다). 사용자가 `state`에 담아 넘긴 요소는 +`Owned = false`로 설치되고 어떤 경로로도 파괴되지 않는다. + +## 5. `attachSlot` 분해 — 사용자가 비용 논거로 확정 + +3차 처리에서 `F-4-3`(`setLength` 위치)을 물었을 때, 사용자가 문제를 더 크게 +재정의했다: "attachSlot 의 기능이 너무 다양해진게 문제같음." + +핵심 진단은 **C6과 C7이 한 함수 안에서 동시에 만족될 수 없다**는 것이었다: + +- **C6**: 부모에게 미는 길이(`setLength(ownerKey, position, ...)`)는 + flush가 끝나야 정해지는 **최종값**이어야 한다. +- **C7**: 부기는 물리 트리 조작보다 항상 먼저 끝나야 한다. + +한 함수엔 `setLength` 슬롯이 하나뿐이라, 앞에 두면 C6 위반(부모가 1회 +헛돎), 뒤에 두면 C7 위반이다. **함수를 둘로 쪼개면 각자 하나씩 만족**한다. + +사용자가 분해안(B)을 검토하고 낸 판단이 결정적이었다: + +> "오히려 안 쪼갤 이유가 정당하지 않아보이는게, 안 쪼갰을 때 구현 상 실수가 +> 많아질 위험이 보임." + +> "이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 +> 부분이고, 지금 적절한 방향으로 이동하지 않으면 계속 실수에 의한 +> 시간/기술비용이 축적될것 같음. 지금 의사코드를 건들이는 비용이, 추후 +> 실수가 누적되는 비용보다 싸다고 생각함." + +이건 `conventions.md`의 "드문 오용이나 가상의 미래 요구까지 +방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 **충돌하지 않는다** — +이 분해가 방어하는 건 가상의 미래 요구가 아니라 **이미 세 번 관측된 버그 +클래스**(`RC-1`/`RC-3`/`RC-4`, 전부 "줄 순서를 잘못 잡아서")이기 때문이다. +분해하면 그 순서 제약이 줄 순서가 아니라 **함수 경계로 강제**된다. + +### 사용자가 스스로 답한 우려 + +같은 회신에서 사용자가 유일한 우려를 제기하고 스스로 결론까지 냈다: + +> "다만 이렇게 된다면 얼마나 nested 든, 하나 마운트되고 하나 마운트되고... +> 각 객체들이 Parent = inst 되어가며 피지컬에 붙어가는 일자 진행인데, 이건 +> 관측 이후 일괄 등록이라는 차이가 있는듯. 하지만 이미 리스트 액티베이션이 +> 자신의 길이를 정확히 알려면 재귀를 뛰어야하는 부분이고 … 이런 사소한, +> 문제를 야기하기 어려운 동작을 원래와 동치시키기 위해 디버깅이 어려운 +> 함수를 만드는건 옳지 않아보임." + +확인해보니 **`Parent` 대입 순서 자체는 안 바뀐다** — 바뀌는 건 +`ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는 +점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는 +동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은 +`research/slot-attach-decomposition.md` 7절. + +## 6. 이 세션이 안 한 것 + +- **5라운드 문항지를 만들지 않았다** — D절 회신의 "이후 stale 만 잡는것으로 + 끝낼 수 있어보임"에 따름. +- **스파이크 두 건은 미실행**(`01` 재작성 = 단일 generalized `for`, + `table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`. +- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는 + **일부러 미뤘다** — 지금 그게 아프다는 증거가 없다. diff --git a/.claude/todos.md b/.claude/todos.md index 7ce1437..cfbe457 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,21 +5,19 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — 1·2·3라운드는 전부 `base/`에 반영 - 완료, [2026-08-19 신설] 4라운드는 문항지 작성만 끝나고 사용자 회신 대기 중.** +00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~4라운드 전부 `base/` + 반영 완료로 종결. 열린 항목 없음.** - **4라운드 — [2026-08-21] 회신 3차 처리 완료, `F-3` 일부만 열려 있음.** 문항지는 + **4라운드 — [2026-08-21] 종결.** 문항지는 `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 - `-response.md`, 처리 결과·재질문·판단 대기 항목은 **`-followup.md`가 - 소스**(여기서 목록을 세지 않음). 회신 중 판단이 명확했던 것은 그 자리에서 - `base/`에 반영했다. **B절(설명 보강 재질문)은 2차 회신으로 전부 확인됐고, - C절 결정도 대부분 반영 완료** — 지금 열려 있는 건 followup **F절**뿐이다: - `F-3`의 **(1)~(3)과 "+"**(detached 요소를 `slot._detached` 필드가 들고 - owner 죽을 때 `Effect`로 정리 / `KeyGone` 세부 / `dispose` 재귀) — 요약은 - followup `G-3`. `F-3`의 `Owned` 설치 플래그와 `F-4` 셋은 전부 닫혔고, - `F-4-3`(`setLength` 위치)은 **`attachSlot` 책임 분해**라는 더 큰 논의로 - 넘어가 `research/slot-attach-decomposition.md`에 준비 자료를 만들어뒀다 - (M6 착수 전 필요). 아래는 그 회신 전 서술: + `-response.md`, 처리 결과 전량은 **`-followup.md`가 소스**(여기서 목록을 + 세지 않음 — 마지막 H절이 최신). 4차 처리로 `F-3`이 전량 확인되며 + **`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**, + **`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해** + (`materializeSlotTree` + `mountSlotTree`, 근거는 + `research/slot-attach-decomposition.md`)가 전부 확정·반영됐다. + **5라운드 문항지는 만들지 않는다** — 사용자 지시("이후 stale 만 잡는것으로 + 끝낼 수 있어보임"). 아래는 그 회신 전 서술: **(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로 @@ -86,12 +84,10 @@ - **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면 위치도 `None`과 같은 최상위 export로 같이 확정). 원문은 `archive/question-resolved.md`. - - **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분** - (`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고 - 참조만 끊김. **[정정, 2026-08-18 `/code-review high` — `ROADMAP.md`의 - M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수 - 전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 - 지나칠 위험이 있었음. + - **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 — + **`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는 + `slot._detached` 필드가 보유하고 owner 사망 시 `Effect`가 정리. + `base/slot-plan.md`의 "`KeyGone`" 절. - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. diff --git a/ROADMAP.md b/ROADMAP.md index 58d0344..8d6aae2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -429,7 +429,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함 (자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈). **쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고 - `:List`의 `reconcile` 중 **값 교체와 `Detach` 경로만**. + `:List`의 `reconcile` 중 **`Owned = false` 설치와 `Detach` 경로** + (**[재정정, 2026-08-21]** 값 교체는 `Owned = true`면 파괴가 맞다 — + `updateFn`이 만든 걸 자기 손으로 못 지우기 때문. `state` + 의미론은 `Owned = false`가 담당). **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고 **[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`이 `nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의 @@ -513,16 +516,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면 저비용 재사용 경로. - **[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** — - `updateFn`이 `Detach, { old = ..., source = ... }`를 반환하면 그 - 자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고, - 보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 - 반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용 - 경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/ - slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절). - **⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를 - 어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론 - 필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저, + **[2026-08-18 신설, 이름 2026-08-19 확정, 2026-08-21 보존 주체 정정] + `Detach` 반환 경로** — `updateFn`이 `Detach`를 반환하면 그 자리는 + **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠진다. + **보존 주체는 `userdata`가 아니라 `slot._detached` 필드**(Slot 필드여야 + `destroySlotTree` walk가 닿고 소유권도 유지됨 — `ud`로는 최종 처분이 + 불가능). reconcile이 `prev`로 그대로 돌려주므로 `ud`에 담을 필요 없이 + **그대로 반환하면 재마운트**되고, 이미 detach 상태에서 또 `Detach`면 + **nop**. 언마운트는 소유권을 유지하는 `rawDetach`를 씀(`rawUnmount` + 아님). `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. 공개 + 표면은 `None`과 같이 패키지 최상위 export(`base/slot-plan.md`의 + "Detach된 요소는 `slot._detached`가 보유한다" 절). + **[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은 + `KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`로 + 한 번 더 물어 처분을 받고, owner가 죽으면 `mountSlotTree`가 건 + `Effect`가 `_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절). + **`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본 + `true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가 + `state`에 담아 넘긴 요소용, `Slot:Add(state)` sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(`prev`류 먼저, `userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가 `prev`보다 앞이었음). **`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열 @@ -588,8 +599,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은 불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절). **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 - 해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기 - Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).** + 해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).** + **[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** — + `materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) + + `mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을 + 순서대로 부르는 **두 줄짜리 공개 `attachSlot`**(이름/시그니처/호출부 + 전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다 + 먼저"가 처음으로 **동시에** 만족되고, 배치 밖 재마운트의 부모 + `recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수 + 경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서" + 나던 버그 클래스가 구조적으로 사라짐. 근거 기록은 + `research/slot-attach-decomposition.md`. + **관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가 + "부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의 + `Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음). - [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로 확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음). `initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상