audit(lifecycle): partial gcconn trick verification, close Relate cycle spike gap
User confirmed the two Roblox-engine-dependent assumptions behind the gcconn trick (ClassName signal never fires, Connection.Connected flips synchronously on Destroy) via a Studio script; record what's verified vs still open in a new audit/ folder and document the GC-trigger technique used. While re-auditing the corpus, found relate-plan.md's ephemeron/ mutual-cycle claim was never actually run in Luau, and CLAUDE.md's open-questions list still listed State as unresolved after it was finalized in session 20 — add spike 18 and fix the stale entry. A background sweep synced .claude/README.md's summary rows against sessions 8-21 and added spikes 19/20 for newer ownership/refcount mechanisms lacking coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGWwZ8khc3Zq7DAnd4Uw6a
This commit is contained in:
parent
2688409ebd
commit
5e6498a3e1
11 changed files with 876 additions and 27 deletions
|
|
@ -15,7 +15,8 @@
|
|||
| `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 |
|
||||
| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) |
|
||||
| `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) |
|
||||
| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. 아직 결과 미확인 — `luau-test/README.md`가 색인 |
|
||||
| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13]** `10`의 일부가 처음으로 실측 확인됨(부분) — 확인된 결과 자체는 `audit/`에 기록, 이 폴더는 스크립트/색인만 유지. 나머지 16개+`10`의 남은 부분은 여전히 결과 미확인 — `luau-test/README.md`가 색인 |
|
||||
| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김 |
|
||||
| `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, CLAUDE.md의 "세션 히스토리" 절에서 각 항목이 여기로 링크. 항상 읽을 필요 없음 — 결정의 논의 과정이 궁금할 때만 |
|
||||
| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 |
|
||||
|
||||
|
|
@ -27,20 +28,20 @@
|
|||
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) |
|
||||
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 |
|
||||
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 |
|
||||
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` |
|
||||
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`) |
|
||||
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌) |
|
||||
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
|
||||
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정 |
|
||||
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) |
|
||||
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존) |
|
||||
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유) |
|
||||
| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 |
|
||||
| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 |
|
||||
| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 |
|
||||
| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 |
|
||||
| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) |
|
||||
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md` |
|
||||
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`에 `v==nil` 가드 추가(더 이상 "retract 불필요" 아님, no-op일 뿐), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지) |
|
||||
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지 |
|
||||
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기 |
|
||||
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 |
|
||||
| `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 |
|
||||
| `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) |
|
||||
|
|
@ -61,10 +62,10 @@
|
|||
| `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 |
|
||||
| `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 |
|
||||
| `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 |
|
||||
| `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 진짜 불리한 점 중 고칠 만한 것 3개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 |
|
||||
| `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 못 고치는 트레이드오프(의도된 설계) 정리. **[2026-08-12 열여덟 번째 세션]** "고칠 만한 것" 절에 남아있던 마지막 두 항목(use-after-destroy 검증 안전망 부재, `:With` 동적 의존성 미지원)도 사용자가 "고칠 필요 없음"으로 최종 판단해 3번 절(못 고치는 트레이드오프)로 이전 — 2번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 |
|
||||
| `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 |
|
||||
| `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1(M0~M4 착수 전 확인 권장) + 11개 우선순위2 + 2개 단순화후보 | 상 — M0 착수 전 최소 우선순위1 항목 확인 권장 |
|
||||
| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 |
|
||||
| `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 |
|
||||
| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 |
|
||||
| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 |
|
||||
|
||||
## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요
|
||||
|
|
|
|||
92
.claude/audit/gcconn-trick-verification.md
Normal file
92
.claude/audit/gcconn-trick-verification.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# gcconn 트릭 — 부분 실측 검증 결과
|
||||
|
||||
**상태**: 부분 확인(2026-08-13). `luau-test/10-roblox-studio-checks.server.luau`의
|
||||
공식 스크립트가 아니라 사용자가 별도로 작성한 저수준 검증 스크립트로
|
||||
확인됨 — `bindLifetime`/`canBound`/`unbindLifetime` 자체의 이중 바인딩
|
||||
로직(A-1/A-2)과 Part B/C는 여전히 미검증. **아래 "아직 확인 안 된 것"이
|
||||
전부 해소되기 전까진 공식 `10` 파일을 완주한 것으로 치지 말 것.**
|
||||
|
||||
## 배경
|
||||
|
||||
`base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute` 구현은 두 가지
|
||||
Roblox 엔진 의존 가정에 기대고 있고, 둘 다 문서만으로는 확정할 수 없어
|
||||
Studio 실측이 필요했음:
|
||||
|
||||
1. `GetPropertyChangedSignal("ClassName")`은 절대 발화하지 않는다(엔진이
|
||||
`ClassName`을 변경 불가능한 프로퍼티로 취급하므로 — 콜백 클로저가
|
||||
`gchold`를 업밸류로 캡처해 살려두는 용도로만 씀).
|
||||
2. `RBXScriptConnection.Connected`는 `inst:Destroy()` 시점에 GC를 기다릴
|
||||
필요 없이 동기적으로 즉시 `false`로 바뀐다 — `canExecute`가 이 값
|
||||
하나로 생존을 판단하므로, 이게 틀리면 설계 전체가 무너짐.
|
||||
|
||||
`luau-test/README.md`는 이 스파이크(`10`의 A 섹션)가 실패하면(신호 발화
|
||||
또는 A-2 실패) "gcconn 트릭 전체를 재검토해야 하는 심각한 발견"이라고
|
||||
못 박아둔 상태였음.
|
||||
|
||||
## 실측 방법
|
||||
|
||||
Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아님) — 요지:
|
||||
|
||||
- `weak = setmetatable({}, {__mode="v"})`에 target을 담아 GC 생존 여부 관찰.
|
||||
- `inst:GetPropertyChangedSignal("ClassName"):Connect(...)`로 gcconn
|
||||
트릭과 동일한 신호를 구독, 콜백 클로저가 `target`을 업밸류로 캡처.
|
||||
- Connection을 변수로 안 잡는 경우(Test 1)와 잡는 경우(Test 2) 둘 다 확인.
|
||||
- `triggerGC()`로 GC 완료를 간접 관찰(기법 상세는
|
||||
`luau-test/gc-trigger-helper.server.luau`).
|
||||
|
||||
## 확인된 것
|
||||
|
||||
1. **`ClassName` PropertyChangedSignal 미발화** — 6 epoch(각 GC 사이클)
|
||||
동안, 그리고 `Destroy()` 전후 모두 `warn`이 한 번도 안 뜸. 트리거
|
||||
조건 1(신호 발화)은 회피 확인.
|
||||
2. **연결이 살아있는 동안 콜백 클로저가 캡처한 값이 GC 안 됨** — Test 1/2
|
||||
둘 다 `weak[1]`이 6 epoch 내내 살아있음. `lifecycle-pattern.md`의
|
||||
"클로저 생존이 곧 gchold 생존" 주장과 일치.
|
||||
3. **`Connection.Connected`가 `Destroy()` 직후 동기적으로 `false`로 전환**
|
||||
— GC를 기다리지 않고 즉시 확인됨(Test 2). `canExecute(inst, value)`의
|
||||
유일한 하드 의존성이 실측으로 확인됨.
|
||||
4. **Destroy 이후 GC를 한 번 더 돌리면 클로저가 캡처했던 값이 실제로
|
||||
수거됨** — `conn` 변수 자체는 스크립트 스코프에 여전히 남아있어도
|
||||
(Connection 객체 자체는 안 죽음), disconnect되면 콜백의 upvalue 참조는
|
||||
놓아준다는 것도 확인 — 메모리 누수로 안 남는다는 근거.
|
||||
|
||||
## 아직 확인 안 된 것
|
||||
|
||||
- **A-1/A-2 (`canBound` 이중 바인딩 게이트, unbind 후 재바인딩 허용)** —
|
||||
`bindLifetime`/`canBound`/`unbindLifetime`/`Subscribed` 로직 자체는
|
||||
이 스크립트에 없음(순수 GC/Connection 메커니즘만 테스트함). `luau-test/
|
||||
README.md`가 명시한 두 번째 "심각한 발견" 트리거 조건(A-2 실패 시
|
||||
`canBound`/`unbindLifetime` 설계 재검토)은 미해소 — 공식 `10` 파일을
|
||||
그대로 실행해서 확인해야 함.
|
||||
- **Part B (Attribute의 Instance 참조 타입 지원)**, **Part C
|
||||
(CollectionService 태그/`GetTagged` 왕복)** — 미실행.
|
||||
- **`inst` 자체를 `__mode="k"` weak key로 쓰는 경로** — 실제 `Relate`
|
||||
구조(`base/relate-plan.md`)는 바깥 테이블이 `inst`를 weak key로 잡는데,
|
||||
이 스크립트는 `__mode="v"` + 정수 리터럴 키만 썼음. `inst`가 다른 곳에서
|
||||
안 참조되면 relate 엔트리(gcconn/gchold/value 전체)가 실제로 죽는
|
||||
경로는 미검증 — `canExecute`가 `.Connected`만으로 정확히 동작하는 한
|
||||
정합성보다는 메모리 누수 방지 쪽 문제라 상대적으로 덜 치명적이지만,
|
||||
완전히 별개 항목이니 열어둘 것. weak-key-on-table 자체는
|
||||
`07-relate-weak-table-gc.luau`가 순수 luau CLI에서 이미 확인했음 —
|
||||
Instance가 plain table과 동일하게 weak key로 동작하는지는 아직 별개로
|
||||
미확인.
|
||||
|
||||
## 부수 발견 — Roblox Studio에서도 GC 완료를 간접 관찰 가능
|
||||
|
||||
`07-relate-weak-table-gc.luau`의 기존 서술("Roblox 실제 게임 스크립트
|
||||
환경에는 `collectgarbage()`가 노출되지 않아 GC 타이밍 검증이 불가능,
|
||||
그래서 순수 luau CLI에서만 해야 함")은 **"명시적 `collectgarbage()` API가
|
||||
없다"는 부분은 여전히 맞지만, "그래서 Studio에서 GC 검증 자체가 불가능하다"는
|
||||
결론은 이번에 반증됨** — weak-value 테이블에 canary를 넣어두고
|
||||
할당 압력(`table.create` 반복)을 걸며 `task.wait`로 기다리면, incremental
|
||||
GC가 실제로 완료되는 시점을 간접 관찰 가능함(사용자가 이번 검증에서
|
||||
확인). `07`의 docstring과 이 기법 자체는 `luau-test/gc-trigger-helper.server.luau`로
|
||||
분리해뒀음 — Studio 기반 스파이크(`10` 등)가 GC 완료를 기다려야 할 때
|
||||
그 파일의 `waitForGC`를 그대로 복붙하면 됨.
|
||||
|
||||
## 다음 확인 시 참고
|
||||
|
||||
공식 `10-roblox-studio-checks.server.luau`를 그대로 돌려서 A-1/A-2/B/C를
|
||||
마저 확인할 것 — 이 문서의 "확인된 것"과 겹치는 A 섹션 앞부분(신호 미발화,
|
||||
Destroy 시 Connected 전환)은 다시 안 봐도 되지만, `canBound`/`unbindLifetime`
|
||||
로직은 반드시 실제로 돌려봐야 함.
|
||||
|
|
@ -187,6 +187,9 @@ function bindLifetime(inst, value)
|
|||
relate:SetStrong(inst, GCHOLD, gchold)
|
||||
gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
|
||||
local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존
|
||||
-- 2026-08-13 부분 실측 확인(미발화 + Destroy 시 Connected 즉시
|
||||
-- 전환) — audit/gcconn-trick-verification.md. canBound 이중
|
||||
-- 바인딩 게이트(A-1/A-2)는 아직 공식 luau-test/10으로 미확인.
|
||||
end)
|
||||
relate:SetStrong(inst, GCCONN, gcconn)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,12 +9,22 @@
|
|||
배경: .claude/base/relate-plan.md "M2 착수 시 실측 확인" 캐비엇.
|
||||
|
||||
중요한 제약: Roblox의 실제 게임 스크립트 환경에는 collectgarbage()가
|
||||
노출되지 않음(강제 GC 트리거 불가) — 그래서 이 GC 타이밍 검증은
|
||||
Roblox Studio가 아니라 반드시 순수 luau CLI에서 해야 함(standalone
|
||||
Luau 인터프리터는 collectgarbage를 허용). Roblox 쪽은 VM/GC 구현
|
||||
자체가 같은 Luau이므로 여기서 확인된 동작이 그대로 적용된다고 가정할
|
||||
수 있지만, "그대로 적용된다"는 가정 자체는 이 스크립트로 검증 불가능한
|
||||
항목으로 남음(참고만 할 것).
|
||||
명시적으로 노출되지 않음 — 그래서 이 스크립트 자체(collectgarbage()를
|
||||
직접 호출)는 Roblox Studio가 아니라 순수 luau CLI에서 돌려야 함
|
||||
(standalone Luau 인터프리터는 collectgarbage를 허용). Roblox 쪽은
|
||||
VM/GC 구현 자체가 같은 Luau이므로 여기서 확인된 동작이 그대로
|
||||
적용된다고 가정할 수 있지만, "그대로 적용된다"는 가정 자체는 이
|
||||
스크립트로 검증 불가능한 항목으로 남음(참고만 할 것).
|
||||
|
||||
**[2026-08-13 갱신]** "그래서 Studio에서는 GC 타이밍 검증 자체가
|
||||
불가능하다"는 예전 결론은 정정됨 — `collectgarbage()` API가 없을
|
||||
뿐, weak-value 테이블에 canary를 넣고 할당 압력을 걸며 기다리는
|
||||
간접 기법으로 Studio에서도 GC 완료를 관찰 가능함이 실측 확인됨
|
||||
(`gc-trigger-helper.server.luau`, `audit/gcconn-trick-verification.md`
|
||||
참고). `10`처럼 Studio 스파이크에서 GC 완료를 기다려야 하면 그
|
||||
헬퍼를 쓸 것 — 이 파일(`07`)은 여전히 순수 luau CLI 전용으로 남김
|
||||
(더 정확한 `collectgarbage()` 직접 호출을 쓸 수 있어 굳이 간접
|
||||
기법으로 바꿀 이유 없음).
|
||||
|
||||
실행: `luau 07-relate-weak-table-gc.luau`
|
||||
]]
|
||||
|
|
|
|||
91
.claude/luau-test/18-relate-mutual-cycle-gc.luau
Normal file
91
.claude/luau-test/18-relate-mutual-cycle-gc.luau
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
--[[
|
||||
검증 대상: `base/relate-plan.md` "위험한 패턴 — 서로 다른 두 `Relate`의
|
||||
상호 강참조 순환" 절의 핵심 주장 — Luau에 ephemeron 테이블이 없어서
|
||||
(https://luau.org/compatibility/ "Lua 5.2" 섹션), 서로 다른 두 weak-key
|
||||
테이블이 서로의 키를 상대방의 강한 값으로 제공하는 순환을 만들면 GC가
|
||||
절대 못 푼다는 것. 지금까지 이 주장은 **공식 문서 인용으로만** 뒷받침돼
|
||||
있었고, 실제 Luau로 재현해본 적은 없었음(2026-08-12 열세/열네 번째
|
||||
세션, `Slot`의 `kSlotMap`/`slotOwner`가 이 패턴에 걸려있었다가 한쪽을
|
||||
`SetWeak`로 낮춰 수정된 실제 사례).
|
||||
|
||||
이 스파이크는 두 부분:
|
||||
1. **위험한 패턴 재현(음성 대조군)** — 두 테이블 다 상대방 키를 강하게
|
||||
들면 실제로 안 죽는지.
|
||||
2. **수정된 패턴 확인(양성 대조군)** — `relate-plan.md`가 처방한 대로
|
||||
한쪽을 weak-value로 낮추면 실제로 죽는지.
|
||||
|
||||
배경: `base/relate-plan.md` "위험한 패턴" 절, `base/slot-plan.md` "Slot과
|
||||
Store 바인드의 관계" 절(`kSlotMap`/`slotOwner` 실사례).
|
||||
|
||||
실행: `luau 18-relate-mutual-cycle-gc.luau` (순수 luau CLI, Roblox 불필요
|
||||
— `collectgarbage()`를 직접 씀, `07`과 같은 이유).
|
||||
]]
|
||||
|
||||
local function newStrongKeyWeakTable()
|
||||
return setmetatable({}, { __mode = "k" }) -- 키만 weak, 값은 기본(strong)
|
||||
end
|
||||
|
||||
print("=== 1. 위험한 패턴 재현 — 상호 강참조 순환은 GC가 안 풀어야 함 ===")
|
||||
do
|
||||
local relateA = newStrongKeyWeakTable() -- inst(weak key) -> value(strong 값)
|
||||
local relateB = newStrongKeyWeakTable() -- value(weak key) -> inst(strong 값)
|
||||
|
||||
local canary
|
||||
do
|
||||
local inst = {}
|
||||
local value = {}
|
||||
relateA[inst] = value
|
||||
relateB[value] = inst
|
||||
-- canary 자신은 inst/value를 약하게만 참조 — 관찰 전용, 생존에 관여 안 함
|
||||
canary = setmetatable({ inst, value }, { __mode = "v" })
|
||||
-- 이 do-블록을 벗어나면 inst/value에 대한 강한 로컬 참조는 사라짐 —
|
||||
-- 남은 건 relateA/relateB 안의 상호 참조뿐
|
||||
end
|
||||
|
||||
collectgarbage()
|
||||
collectgarbage()
|
||||
|
||||
local instAlive = canary[1] ~= nil
|
||||
local valueAlive = canary[2] ~= nil
|
||||
print("inst 살아있음?", instAlive, "(기대: true — 상호 순환 때문에 안 풀림, ephemeron 없음)")
|
||||
print("value 살아있음?", valueAlive, "(기대: true — 같은 이유)")
|
||||
if not instAlive and not valueAlive then
|
||||
warn("[예상 밖] 상호 순환인데도 GC됨 — Luau가 이 케이스를 실제로 처리한다는 뜻, relate-plan.md의 '위험한 패턴' 경고 자체를 재검토해야 함")
|
||||
end
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 2. 수정된 패턴 — relate-plan.md 처방대로 한쪽을 weak-value로 낮추면 풀려야 함 ===")
|
||||
do
|
||||
local relateA = newStrongKeyWeakTable() -- inst(weak key) -> value(strong 값), 그대로 둠
|
||||
local relateBWeak = setmetatable({}, { __mode = "kv" }) -- value(weak key) -> inst(이번엔 값도 weak)
|
||||
|
||||
local canary
|
||||
do
|
||||
local inst = {}
|
||||
local value = {}
|
||||
relateA[inst] = value
|
||||
relateBWeak[value] = inst
|
||||
canary = setmetatable({ inst, value }, { __mode = "v" })
|
||||
end
|
||||
|
||||
collectgarbage()
|
||||
collectgarbage()
|
||||
|
||||
local instAlive = canary[1] ~= nil
|
||||
local valueAlive = canary[2] ~= nil
|
||||
print("inst 살아있음?", instAlive, "(기대: false — 이번엔 GC됨)")
|
||||
print("value 살아있음?", valueAlive, "(기대: false — 같은 이유)")
|
||||
if instAlive or valueAlive then
|
||||
warn("[예상 밖] weak-value로 낮췄는데도 안 죽음 — relate-plan.md의 처방(SetWeak로 낮추기)이 실제로는 안 통한다는 뜻, 최우선으로 재검토")
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
확인 포인트:
|
||||
1번 섹션은 true/true, 2번 섹션은 false/false가 나와야 relate-plan.md의
|
||||
주장(위험한 패턴 존재 + 처방 효과 있음) 둘 다 실측 확인된 것. 어느 한쪽이
|
||||
라도 기대와 다르면(특히 warn이 뜨면) `base/relate-plan.md` "위험한 패턴"
|
||||
절과 `base/slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 설계
|
||||
전체를 재검토해야 함 — 이 사례 하나가 Slot GC 설계의 유일한 근거였음.
|
||||
]]
|
||||
278
.claude/luau-test/19-ownership-refcount-relate-patterns.luau
Normal file
278
.claude/luau-test/19-ownership-refcount-relate-patterns.luau
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
--[[
|
||||
검증 대상: "retract는 항상 불림" 전면 정정(2026-08-12 열한 번째 세션) 이후
|
||||
새로 생긴 세 가지 소유권/참조카운트 추적 로직이 실제로 짜인 대로 동작하는지 —
|
||||
전부 여러 위치/여러 사이클에 걸쳐 상태가 정확히 갱신되는지가 핵심이라
|
||||
손으로 추론만으로는 놓치기 쉬운 클래스(이 프로젝트가 이미 같은 클래스에서
|
||||
실제 버그를 두 번 냄: `retractUnder`의 and/or 삼항 falsy 버그, Slot
|
||||
`recompute`의 off-by-one). 셋 다 Roblox 엔진/GC 타이밍과 무관한 순수 Luau
|
||||
테이블 로직이지만, 03/04/11번 스파이크가 커버하는 것과는 다른 새 알고리즘
|
||||
모양(여러 위치가 하나의 이름/자리를 공유하는 참조 카운트, 캐싱된 키 객체의
|
||||
사이클 간 재사용, "같은 owner면 무시/다른 owner면 error" 3분기)이라 별도로
|
||||
실측이 필요하다고 판단해 신규 작성함.
|
||||
|
||||
A) Tag 참조 카운트 — `base/tag-plan.md` "메커니즘 — TagHandler" 절의
|
||||
`kTagMap`/`tagNameMap`. 서로 다른 두 위치가 같은 이름을 겹쳐 가질 때
|
||||
(`Frame { Tag("a"), Tag("a","b") }`류) 한쪽이 이름을 잃어도 다른 쪽이
|
||||
아직 쥐고 있으면 실제 `RemoveTag`가 안 불려야 함(웹 className 합집합
|
||||
시맨틱).
|
||||
|
||||
B) Attribute 소유권 — `base/attribute-plan.md` "이름 소유권" 절의 `rawNew`+
|
||||
`owners` 레지스트리. 직접 리터럴 쓰기와 그룹 위임이 같은 이름을 동시에
|
||||
관리하려 하면 즉시 error. 그룹의 "남아있는 이름"은 캐싱된 같은 키 객체를
|
||||
여러 사이클에 걸쳐 재사용해야 함(매번 새 키를 만들면 owners 레지스트리가
|
||||
자기 자신과 충돌하는 오탐이 남).
|
||||
|
||||
C) Slot 요소 소유권 — `base/slot-plan.md` "요소 소유권 — `elementOwner`" 절의
|
||||
`claimOwner`/`releaseOwner`. 같은 element가 이미 다른 곳에 마운트돼
|
||||
있으면 error, 같은 owner가 다시 클레임하면 조용한 no-op(재귀 재emit
|
||||
대응), 해제 후 다른 곳에 새로 붙는 건 정상 허용. top-level(Dispatch)과
|
||||
nested(`Add`) 경로가 **같은** 레지스트리를 공유해야 서로의 마운트를 잡음.
|
||||
|
||||
실행: `luau 19-ownership-refcount-relate-patterns.luau`
|
||||
]]
|
||||
|
||||
-- ===== 공용: Relate 흉내(이 스파이크는 순수 로직 검증이 목적이라 weak-key는
|
||||
-- 07/18번이 이미 따로 다룸 — 여기선 plain 테이블로 단순화) =====
|
||||
|
||||
local function makeRegistry()
|
||||
local t = {}
|
||||
return {
|
||||
get = function(_, a, b)
|
||||
local sub = t[a]
|
||||
return sub and sub[b]
|
||||
end,
|
||||
set = function(_, a, b, v)
|
||||
t[a] = t[a] or {}
|
||||
t[a][b] = v
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function report(name, ok, expected, actual)
|
||||
local pass = ok
|
||||
print(string.format(" [%s] %s%s", pass and "PASS" or "FAIL", name, pass and "" or string.format(" (expected=%s actual=%s)", tostring(expected), tostring(actual))))
|
||||
return pass
|
||||
end
|
||||
|
||||
local allPass = true
|
||||
|
||||
-- ===== A. Tag 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가짐 =====
|
||||
|
||||
print("=== A. Tag kTagMap/tagNameMap — 참조 카운트 ===")
|
||||
|
||||
do
|
||||
local kTagMap = makeRegistry() -- (inst,k) -> Tag(흉내: {names={...}})
|
||||
local tagNameMap = makeRegistry() -- (inst,name) -> {[Tag]=true}
|
||||
local removeLog = {}
|
||||
local addLog = {}
|
||||
|
||||
local function names(tagList)
|
||||
local out = {}
|
||||
for _, n in tagList do
|
||||
out[n] = true
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function makeTag(...)
|
||||
return { __names = names({ ... }) }
|
||||
end
|
||||
local function tagNames(tag)
|
||||
local out = {}
|
||||
for n in tag.__names do
|
||||
table.insert(out, n)
|
||||
end
|
||||
return out
|
||||
end
|
||||
local function tagContains(tag, name)
|
||||
return tag.__names[name] == true
|
||||
end
|
||||
|
||||
local function TagRetract(inst, k, newv)
|
||||
local oldv = kTagMap:get(inst, k)
|
||||
if not oldv then
|
||||
return
|
||||
end
|
||||
local newvIsTag = newv ~= nil and newv.__names ~= nil
|
||||
for _, name in tagNames(oldv) do
|
||||
local holders = tagNameMap:get(inst, name) or {}
|
||||
holders[oldv] = nil
|
||||
tagNameMap:set(inst, name, holders)
|
||||
if next(holders) == nil and not (newvIsTag and tagContains(newv, name)) then
|
||||
table.insert(removeLog, name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function TagProcess(inst, k, v)
|
||||
for _, name in tagNames(v) do
|
||||
local holders = tagNameMap:get(inst, name) or {}
|
||||
if next(holders) == nil then
|
||||
table.insert(addLog, name)
|
||||
end
|
||||
holders[v] = true
|
||||
tagNameMap:set(inst, name, holders)
|
||||
end
|
||||
kTagMap:set(inst, k, v)
|
||||
end
|
||||
|
||||
-- Frame { Tag("a"), Tag("a", "b") } — 두 위치(k=1, k=2)가 "a"를 겹쳐 가짐
|
||||
local tagPos1 = makeTag("a")
|
||||
local tagPos2 = makeTag("a", "b")
|
||||
TagRetract("Frame1", 1, tagPos1)
|
||||
TagProcess("Frame1", 1, tagPos1)
|
||||
TagRetract("Frame1", 2, tagPos2)
|
||||
TagProcess("Frame1", 2, tagPos2)
|
||||
|
||||
-- Frame{Tag("a"), Tag("a","b")} 마운트 직후: "a"는 두 위치가 겹쳐 가지므로
|
||||
-- AddTag("a")는 첫 홀더가 생길 때 한 번만 실제로 불려야 함(로그 = {a, b})
|
||||
allPass = report("초기 마운트: AddTag 로그", #addLog == 2 and addLog[1] == "a" and addLog[2] == "b", "{a,b}", table.concat(addLog, ",")) and allPass
|
||||
|
||||
-- 위치1이 "a"를 잃음(Tag()로 교체, 빈 값) — 위치2가 아직 "a"를 쥐고 있으므로
|
||||
-- 실제 RemoveTag("a")는 안 불려야 함
|
||||
local emptyTag = makeTag()
|
||||
TagRetract("Frame1", 1, emptyTag)
|
||||
TagProcess("Frame1", 1, emptyTag)
|
||||
|
||||
allPass = report("위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림", #removeLog == 0, "0", #removeLog) and allPass
|
||||
|
||||
-- 위치2도 "a"를 잃음 — 이제 진짜로 RemoveTag("a")가 불려야 함(b는 그대로 유지)
|
||||
local tagPos2b = makeTag("b")
|
||||
TagRetract("Frame1", 2, tagPos2b)
|
||||
TagProcess("Frame1", 2, tagPos2b)
|
||||
|
||||
allPass = report("마지막 홀더도 a를 잃으면 RemoveTag(a) 실제로 불림", #removeLog == 1 and removeLog[1] == "a", "{a}", table.concat(removeLog, ",")) and allPass
|
||||
end
|
||||
|
||||
-- ===== B. Attribute 소유권 — rawNew 전용 키 + owners 레지스트리 =====
|
||||
|
||||
print()
|
||||
print("=== B. Attribute owners 레지스트리 — 소유권 충돌 + 캐시 재사용 ===")
|
||||
|
||||
do
|
||||
local owners = makeRegistry() -- (inst,name) -> keyObject
|
||||
local function rawNew(name)
|
||||
return { __name = name } -- identity가 의미있는 새 키 객체
|
||||
end
|
||||
|
||||
local function AttributeKeyProcess(inst, k, v)
|
||||
local name = k.__name
|
||||
local map = owners:get(inst, "map") or {}
|
||||
local current = map[name]
|
||||
if current ~= nil and current ~= k then
|
||||
error(string.format('attribute "%s"는 이미 다른 AttributeKey가 관리 중', name))
|
||||
end
|
||||
if v == nil then
|
||||
map[name] = nil
|
||||
else
|
||||
map[name] = k
|
||||
end
|
||||
owners:set(inst, "map", map)
|
||||
end
|
||||
|
||||
local directKey = rawNew("Enabled") -- 직접 리터럴 쓰기 흉내(공개 캐시 대신 여기선 그냥 키 하나)
|
||||
AttributeKeyProcess("Frame1", directKey, true)
|
||||
|
||||
local ok1 = pcall(function()
|
||||
local groupKey = rawNew("Enabled") -- 그룹이 rawNew로 만든 별개 키 객체(이름은 같음)
|
||||
AttributeKeyProcess("Frame1", groupKey, false)
|
||||
end)
|
||||
allPass = report("직접 쓰기 + 그룹이 같은 이름을 동시에 관리 -> error", ok1 == false, false, ok1) and allPass
|
||||
|
||||
-- 그룹 자신의 diff 사이클 — 같은 이름이 여러 사이클에 걸쳐 살아남으면
|
||||
-- 캐싱된 같은 키 객체를 재사용해야 함(안 그러면 owners가 자기 자신과 충돌)
|
||||
local groupCache = {} -- {[name] = keyObject} — attribute-plan.md의 "이름 -> 그 이름 전용 키" 맵
|
||||
local function groupCycle(names)
|
||||
for _, name in names do
|
||||
local key = groupCache[name] or rawNew(name)
|
||||
groupCache[name] = key
|
||||
AttributeKeyProcess("Frame2", key, true) -- 실제로는 Dispatch.retractUnder+process 페어, 여기선 process만 흉내
|
||||
end
|
||||
end
|
||||
|
||||
groupCycle({ "Health", "Mana" })
|
||||
local firstHealthKey = groupCache["Health"]
|
||||
groupCycle({ "Health", "Mana" }) -- 2번째 사이클, 같은 이름들 살아남음
|
||||
|
||||
local ok2 = groupCache["Health"] == firstHealthKey
|
||||
allPass = report("남아있는 이름은 사이클 간 같은 키 객체 재사용(재생성 아님)", ok2, true, ok2) and allPass
|
||||
|
||||
local ok3, err3 = pcall(function()
|
||||
-- 캐시를 무시하고 매번 새 키를 만들면(버그 시뮬레이션) 자기 자신과도 충돌해야 정상
|
||||
local staleKey = rawNew("Health")
|
||||
AttributeKeyProcess("Frame2", staleKey, true)
|
||||
end)
|
||||
allPass = report("캐시 우회하고 새 키로 같은 이름 쓰면 자기 자신과도 충돌(캐시 재사용이 왜 필수인지 반증)", ok3 == false, false, ok3) and allPass
|
||||
end
|
||||
|
||||
-- ===== C. Slot elementOwner — claimOwner/releaseOwner =====
|
||||
|
||||
print()
|
||||
print("=== C. Slot elementOwner — 다중 마운트 금지 + 같은 owner는 no-op ===")
|
||||
|
||||
do
|
||||
local elementOwner = makeRegistry()
|
||||
local OWNER = "__owner"
|
||||
|
||||
local function claimOwner(element, ownerKey)
|
||||
local current = elementOwner:get(element, OWNER)
|
||||
if current == ownerKey then
|
||||
return false -- 이미 같은 owner — no-op
|
||||
end
|
||||
if current ~= nil then
|
||||
error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
|
||||
end
|
||||
elementOwner:set(element, OWNER, ownerKey)
|
||||
return true
|
||||
end
|
||||
|
||||
local function releaseOwner(element, ownerKey)
|
||||
if elementOwner:get(element, OWNER) == ownerKey then
|
||||
elementOwner:set(element, OWNER, nil)
|
||||
end
|
||||
end
|
||||
|
||||
local slotA = { __name = "slotA" }
|
||||
local frame1 = { __name = "Frame1" }
|
||||
local frame2 = { __name = "Frame2" }
|
||||
local outerSlot = { __name = "outerSlot" }
|
||||
|
||||
local claimed1 = claimOwner(slotA, frame1) -- top-level Dispatch 마운트
|
||||
allPass = report("최초 클레임은 true(실제로 붙음)", claimed1 == true, true, claimed1) and allPass
|
||||
|
||||
local claimed2 = claimOwner(slotA, frame1) -- 같은 owner가 재emit(재귀 재계산 등)
|
||||
allPass = report("같은 owner 재클레임은 false(no-op, 재파괴/재생성 없음)", claimed2 == false, false, claimed2) and allPass
|
||||
|
||||
local ok4 = pcall(function()
|
||||
claimOwner(slotA, outerSlot) -- nested Add가 같은 slotA를 다른 곳에 붙이려 함
|
||||
end)
|
||||
allPass = report("top-level이 이미 소유 중인데 nested Add가 같은 element를 또 클레임 -> error", ok4 == false, false, ok4) and allPass
|
||||
|
||||
releaseOwner(slotA, frame1) -- top-level에서 정상 해제(retract)
|
||||
local claimed3 = claimOwner(slotA, outerSlot) -- 해제 후엔 다른 곳에 정상적으로 다시 붙을 수 있음
|
||||
allPass = report("release 이후엔 다른 owner가 정상 클레임 가능", claimed3 == true, true, claimed3) and allPass
|
||||
|
||||
local ok5 = pcall(function()
|
||||
claimOwner(slotA, frame2) -- outerSlot이 아직 쥐고 있는데 frame2가 가로채려 함
|
||||
end)
|
||||
allPass = report("release 안 된 상태에서 제3자가 가로채려 하면 -> error", ok5 == false, false, ok5) and allPass
|
||||
end
|
||||
|
||||
print()
|
||||
print(allPass and "=== 전체 PASS ===" or "=== 하나 이상 FAIL — 위 로그에서 어느 케이스인지 확인할 것 ===")
|
||||
|
||||
--[[
|
||||
확인 포인트:
|
||||
1. A: "위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림" — 이게 FAIL이면
|
||||
웹 className류 합집합 시맨틱이 실제로 안 되는 것이므로 tag-plan.md의
|
||||
`kTagMap`/`tagNameMap` 알고리즘 자체를 재검토해야 함(최우선 보고).
|
||||
2. B: "캐시 우회하고 새 키로 같은 이름 쓰면 자기 자신과도 충돌" 케이스가
|
||||
FAIL(즉 에러가 안 남)이면 오히려 좋은 신호가 아니라, attribute-plan.md가
|
||||
"캐시가 그룹 값 교체를 넘어 영속돼야 한다"고 요구하는 이유 자체가
|
||||
이 스파이크에서 재현이 안 된 것 — 이 경우 이 검증 케이스 설계를
|
||||
재검토할 것(알고리즘이 아니라 테스트 설계 문제일 수 있음).
|
||||
3. C: 세 가지 분기(같은 owner=no-op, 다른 owner=error, release 후 재클레임=
|
||||
정상)가 전부 정확히 갈리는가 — 이 셋 중 하나라도 안 갈리면 Slot의
|
||||
"재귀 재emit마다 서브트리가 파괴됐다 재생성되는" 파괴적 버그
|
||||
(slot-plan.md 참고)로 직결되므로 FAIL이면 최우선 보고.
|
||||
]]
|
||||
143
.claude/luau-test/20-slot-splice-index-arithmetic.luau
Normal file
143
.claude/luau-test/20-slot-splice-index-arithmetic.luau
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
--[[
|
||||
검증 대상: `Slot:Splice(index, removeCount, ...newElements)`
|
||||
(`base/slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절, 2026-08-12
|
||||
열다섯 번째 세션 신규) — 한 구간을 제거하고 동시에 새 요소를 그 자리에
|
||||
삽입하는 shift+recompute 1회짜리 연산이, 문서 자신이 명시한 불변식
|
||||
("`Splice`의 결과는 항상 `Extract` 반복 + `Add` 반복으로도 재현 가능해야
|
||||
함")을 실제로 만족하는지.
|
||||
|
||||
왜 이게 필요한가: 이 프로젝트는 인덱스 시프트 계산에서 이미 한 번 실제
|
||||
버그를 냈음(`Dispatch.recompute`의 offset이 자기 자신을 포함해 누적되던
|
||||
off-by-one, 2026-08-11 여섯 번째 세션, `base/bind-system-plan.md` 참고) —
|
||||
Splice는 제거 구간과 삽입 구간이 서로 다른 길이일 때 뒤 요소들이 얼마나
|
||||
밀리는지를 한 번에 계산해야 해서 같은 클래스의 off-by-one 위험이 있음.
|
||||
Roblox 엔진/GC 타이밍과 무관한 순수 인덱스 산술이라 `luau` CLI로 바로
|
||||
검증 가능.
|
||||
|
||||
방법: `rawSplice`를 흉내낸 구현과, 그와 독립적으로 "제거 구간을 하나씩
|
||||
`Extract`, 그 자리에 하나씩 `Add`"를 반복하는 훨씬 단순하고 명백히 옳은
|
||||
참조 구현(reference impl) 둘을 각 케이스에 나란히 돌려 최종 배열과 반환된
|
||||
제거분이 완전히 일치하는지 비교 — 손으로 기대값을 미리 계산해두지 않아도
|
||||
두 구현이 divergence하면 그 자체가 버그 신호.
|
||||
|
||||
실행: `luau 20-slot-splice-index-arithmetic.luau`
|
||||
]]
|
||||
|
||||
-- ===== 참조 구현: Extract 반복 + Add 반복(단순하고 명백히 옳음) =====
|
||||
|
||||
local function referenceSplice(arr, index, removeCount, newElements)
|
||||
local out = table.clone(arr)
|
||||
local removed = {}
|
||||
-- removeCount개를 index 위치에서 하나씩 Extract(뒤 요소가 당겨짐)
|
||||
for _ = 1, removeCount do
|
||||
table.insert(removed, table.remove(out, index))
|
||||
end
|
||||
-- newElements를 index 위치부터 하나씩 Add(뒤 요소가 밀림)
|
||||
for i, el in newElements do
|
||||
table.insert(out, index + i - 1, el)
|
||||
end
|
||||
return out, removed
|
||||
end
|
||||
|
||||
-- ===== 실측 대상: shift 1회 + recompute 1회로 묶은 구현 =====
|
||||
-- base/slot-plan.md가 "새 능력 추가 아님, 순수 최적화"라고 명시한 대로,
|
||||
-- table.move 기반 단일 시프트로 같은 결과를 내야 함.
|
||||
|
||||
local function rawSplice(arr, index, removeCount, newElements)
|
||||
local n = #arr
|
||||
local removed = {}
|
||||
for i = 0, removeCount - 1 do
|
||||
removed[i + 1] = arr[index + i]
|
||||
end
|
||||
|
||||
local newCount = #newElements
|
||||
local delta = newCount - removeCount -- 양수면 뒤가 더 밀림, 음수면 당겨짐
|
||||
|
||||
if delta > 0 then
|
||||
-- 뒤 요소들을 delta칸 뒤로 미리 밀어 공간 확보(뒤에서부터 복사해야 겹침 안 깨짐)
|
||||
for i = n, index + removeCount, -1 do
|
||||
arr[i + delta] = arr[i]
|
||||
end
|
||||
elseif delta < 0 then
|
||||
-- 뒤 요소들을 -delta칸 앞으로 당김(앞에서부터 복사)
|
||||
for i = index + removeCount, n do
|
||||
arr[i + delta] = arr[i]
|
||||
end
|
||||
for i = n + delta + 1, n do
|
||||
arr[i] = nil
|
||||
end
|
||||
end
|
||||
|
||||
for i, el in newElements do
|
||||
arr[index + i - 1] = el
|
||||
end
|
||||
|
||||
return arr, removed
|
||||
end
|
||||
|
||||
-- ===== 케이스들 — 경계값 위주(off-by-one이 실제로 숨을 만한 자리) =====
|
||||
|
||||
local cases = {
|
||||
{ name = "제거=삽입 길이 같음(제자리 교체)", arr = { "a", "b", "c", "d" }, index = 2, removeCount = 2, newElements = { "X", "Y" } },
|
||||
{ name = "삽입 > 제거(뒤가 밀림)", arr = { "a", "b", "c", "d" }, index = 2, removeCount = 1, newElements = { "X", "Y", "Z" } },
|
||||
{ name = "제거 > 삽입(뒤가 당겨짐)", arr = { "a", "b", "c", "d", "e" }, index = 2, removeCount = 3, newElements = { "X" } },
|
||||
{ name = "removeCount=0(순수 삽입, Add와 동치여야 함)", arr = { "a", "b", "c" }, index = 2, removeCount = 0, newElements = { "X", "Y" } },
|
||||
{ name = "newElements 없음(순수 제거, Extract 반복과 동치여야 함)", arr = { "a", "b", "c", "d" }, index = 2, removeCount = 2, newElements = {} },
|
||||
{ name = "맨 앞(index=1)", arr = { "a", "b", "c" }, index = 1, removeCount = 1, newElements = { "X", "Y" } },
|
||||
{ name = "맨 끝까지 제거(index+removeCount-1 == #arr)", arr = { "a", "b", "c", "d" }, index = 3, removeCount = 2, newElements = { "X" } },
|
||||
{ name = "끝에 순수 추가(index = #arr+1, removeCount=0)", arr = { "a", "b", "c" }, index = 4, removeCount = 0, newElements = { "X", "Y" } },
|
||||
{ name = "전체 교체(index=1, removeCount=#arr)", arr = { "a", "b", "c" }, index = 1, removeCount = 3, newElements = { "X", "Y", "Z", "W" } },
|
||||
{ name = "단일 원소 배열에서 단일 교체", arr = { "a" }, index = 1, removeCount = 1, newElements = { "X" } },
|
||||
{ name = "삽입 개수가 제거보다 훨씬 큼(delta 큰 양수)", arr = { "a", "b", "c" }, index = 2, removeCount = 1, newElements = { "X", "Y", "Z", "W", "V" } },
|
||||
}
|
||||
|
||||
local function arraysEqual(a, b)
|
||||
if #a ~= #b then
|
||||
return false
|
||||
end
|
||||
for i = 1, #a do
|
||||
if a[i] ~= b[i] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local allPass = true
|
||||
|
||||
for _, case in cases do
|
||||
local refArr, refRemoved = referenceSplice(case.arr, case.index, case.removeCount, case.newElements)
|
||||
local rawArr = table.clone(case.arr)
|
||||
local _, rawRemoved = rawSplice(rawArr, case.index, case.removeCount, case.newElements)
|
||||
|
||||
local finalOk = arraysEqual(refArr, rawArr)
|
||||
local removedOk = arraysEqual(refRemoved, rawRemoved)
|
||||
local pass = finalOk and removedOk
|
||||
|
||||
allPass = allPass and pass
|
||||
|
||||
print(string.format(" [%s] %s", pass and "PASS" or "FAIL", case.name))
|
||||
if not pass then
|
||||
print(" reference 최종:", table.concat(refArr, ","), " / raw 최종:", table.concat(rawArr, ","))
|
||||
print(" reference 제거분:", table.concat(refRemoved, ","), " / raw 제거분:", table.concat(rawRemoved, ","))
|
||||
end
|
||||
end
|
||||
|
||||
print()
|
||||
print(allPass and "=== 전체 PASS ===" or "=== 하나 이상 FAIL — off-by-one 등 shift 계산 버그 가능성, 최우선 보고 ===")
|
||||
|
||||
--[[
|
||||
확인 포인트:
|
||||
1. 모든 케이스가 PASS인가 — 특히 "delta 큰 양수"/"전체 교체"/"맨 끝까지
|
||||
제거" 세 케이스는 시프트 방향(뒤에서부터 vs 앞에서부터 복사)을 잘못
|
||||
고르면 데이터가 겹쳐써지는 클래식 버그가 나는 자리라 우선 확인할 것.
|
||||
2. 이 파일의 `rawSplice`는 문서(`slot-plan.md`)가 서술한 알고리즘을
|
||||
이 스파이크 작성자가 재구현한 것 — 실제 M6 구현이 이거랑 정확히
|
||||
같은 모양일 필요는 없지만, "제거+삽입을 시프트 1회로 묶어도
|
||||
Extract반복+Add반복과 결과가 항상 같다"는 불변식 자체가 성립하는지
|
||||
확인하는 게 이 스파이크의 목적. FAIL이 나면 `rawSplice`가 아니라
|
||||
불변식 자체(또는 이 스파이크의 케이스 설계)를 의심해볼 것.
|
||||
3. 이 스파이크는 논리적 정합성만 다룸 — 실제 Slot 구현이 여기에 더해
|
||||
다뤄야 하는 것(요소 타입 검증, 이미 마운트된 element 거부, 물리
|
||||
detach/reattach 타이밍)은 범위 밖, `slot-plan.md` 본문 참고.
|
||||
]]
|
||||
|
|
@ -23,7 +23,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
|
||||
| 환경 | 필요한 것 | 해당 파일 |
|
||||
|---|---|---|
|
||||
| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분), 17 |
|
||||
| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분), 17, 18, 19, 20 |
|
||||
| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14, 15, 16 |
|
||||
| **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 |
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" |
|
||||
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>`가 `State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 |
|
||||
| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 |
|
||||
| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
|
||||
| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 — **[2026-08-13]** A 섹션 앞부분(신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됨, `audit/gcconn-trick-verification.md` 참고. A-1/A-2(`canBound` 게이트)/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
|
||||
| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 |
|
||||
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
|
||||
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>`가 `Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) |
|
||||
|
|
@ -63,6 +63,18 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) |
|
||||
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 |
|
||||
| `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 |
|
||||
| `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 |
|
||||
| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규]** "retract는 항상 불림" 정정(2026-08-12 열한 번째 세션) 이후 새로 생긴 세 소유권/참조카운트 알고리즘(A: Tag `kTagMap`/`tagNameMap` 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`, B: Attribute `rawNew`+`owners` 소유권 — 충돌 시 error + 사이클 간 캐시 키 재사용, C: Slot `elementOwner`의 `claimOwner`/`releaseOwner` — 같은 owner 재클레임은 no-op, 다른 owner는 error)가 실제로 정확히 갈리는지 | `tag-plan.md` "메커니즘" 절, `attribute-plan.md` "이름 소유권" 절, `slot-plan.md` "요소 소유권 — `elementOwner`" 절(전부 2026-08-12 세션들, 03/04/11번과는 다른 새 알고리즘 모양이라 별도 실측 필요하다고 판단) |
|
||||
| `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `bind-system-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) |
|
||||
|
||||
## 공통 유틸리티
|
||||
|
||||
- `gc-trigger-helper.server.luau` — Roblox Studio(collectgarbage() 미노출
|
||||
환경)에서 GC 완료를 간접 관찰하는 `waitForGC()` 스니펫. Studio 기반
|
||||
스파이크(`10` 등)가 "GC가 끝날 때까지 기다렸다가 weak 참조가 사라졌는지
|
||||
확인"해야 할 때 그대로 복붙해서 쓸 것. 순수 luau CLI 스크립트(`07` 등)는
|
||||
`collectgarbage()`를 직접 부르면 되므로 이 헬퍼가 필요 없음 — 발견 경위는
|
||||
`audit/gcconn-trick-verification.md` 참고.
|
||||
|
||||
## 갱신 이력
|
||||
|
||||
|
|
@ -115,6 +127,33 @@ Luau로 부딪혀본 적은 없다는 걸 핸드오버 점검 중 발견 — `16
|
|||
스케치, 타입체크 전용)/`17`(제네릭 __index 체이닝, 런타임)로 신규 추가.
|
||||
둘 다 이전까지 이 폴더 어디에도 커버 대상이 없던 완전히 새 항목.
|
||||
|
||||
**7차 (2026-08-13)**: `10`의 A 섹션 앞부분(ClassName 신호 미발화,
|
||||
`Destroy()` 시 `Connection.Connected` 즉시 전환)을 사용자가 공식 파일이
|
||||
아닌 자작 스크립트로 먼저 실측 — 결과는 `.claude/audit/
|
||||
gcconn-trick-verification.md`에 정리(부분 확인, A-1/A-2/B/C는 아직 official
|
||||
`10`으로 재확인 필요). 부수적으로 Studio에서도 GC 완료를 간접 관찰하는
|
||||
기법을 발견해 `gc-trigger-helper.server.luau`로 분리, `07`의 "Studio에서
|
||||
GC 검증 불가" 서술도 이에 맞춰 정정.
|
||||
|
||||
**8차 (2026-08-13)**: `18` 신규 추가 — 2026-08-12 열세/열네 번째 세션에서
|
||||
`relate-plan.md`에 확정된 "두 `Relate` 상호 강참조 순환은 ephemeron 없이는
|
||||
GC가 안 풂" 주장이 지금까지 공식 문서 인용으로만 뒷받침돼 있었고 실제
|
||||
Luau로 재현해본 적이 없었던 갭 — `Slot`의 `kSlotMap`/`slotOwner` GC 수정
|
||||
사례(session 13/14) 전체가 이 사례 하나에 기대고 있어 우선순위 있게 추가.
|
||||
|
||||
**9차 (2026-08-13)**: CLAUDE.md 세션 8~21(대부분 2026-08-12) 전체를 대상으로
|
||||
"새 메커니즘 중 실 Luau로 안 부딪혀본 게 더 있는가"를 재점검 — Ref/Slot의
|
||||
`Relate` diff 기반 retract(세션 8/9)는 03/04번이 이미 검증한 것과 같은
|
||||
클래스의 재귀 dispatch/체인 로직이라 스킵. 반면 Tag 참조 카운트(세션 11/16)
|
||||
+ Attribute `rawNew` 소유권(세션 10) + Slot `elementOwner` 소유권 판정
|
||||
(세션 12, GC 쪽은 이미 18번이 커버)은 "여러 위치가 하나의 이름/자리를
|
||||
공유"하는 셋 다 새로운 알고리즘 모양이라 `19` 하나로 묶어 신규 추가.
|
||||
`Slot:Splice`(세션 15)는 순수 index 산술이지만 이 프로젝트가 같은 클래스
|
||||
(`recompute` off-by-one)에서 실제 버그를 낸 전례가 있어 `20`으로 별도
|
||||
신규 추가. 그 외(`Tag:Added`의 vararg→`string|{string}` 전환, Attribute의
|
||||
"retract 완전 no-op" 재정정 등)는 단순 분기/타입 정리라 스파이크 불필요로
|
||||
판단, 추가 안 함.
|
||||
|
||||
## 결과 확인 후 할 일
|
||||
|
||||
각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면
|
||||
|
|
@ -127,9 +166,11 @@ Luau로 부딪혀본 적은 없다는 걸 핸드오버 점검 중 발견 — `16
|
|||
`collectgarbage("count")` 수치 변화를 같이 알려줄 것 — 정확한 판정이
|
||||
어려운 항목이라 참고 신호로만 쓸 것.
|
||||
- `10`의 A 섹션에서 만약 `warn`이 실제로 뜨면(ClassName Changed가
|
||||
발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것.
|
||||
A-2(재-bindLifetime 허용 여부)가 실패하면 `canBound`/`unbindLifetime`
|
||||
설계 자체를 재검토해야 함.
|
||||
발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것
|
||||
— **[2026-08-13] 이 조건은 이미 회피 확인됨**(`audit/
|
||||
gcconn-trick-verification.md`), 재확인 불필요. A-2(재-bindLifetime 허용
|
||||
여부)가 실패하면 `canBound`/`unbindLifetime` 설계 자체를 재검토해야
|
||||
함 — **이건 아직 미확인, 공식 `10` 파일로 꼭 돌려볼 것.**
|
||||
- `11`은 전부 PASS가 기대값 — FAIL이 하나라도 있으면 어느 케이스인지
|
||||
그대로 알려줄 것(특히 "변환 함수가 반환한 값" 케이스는 놓치기 쉬운
|
||||
경로라 실제 구현에서도 잘 짜였는지 중요한 신호).
|
||||
|
|
@ -153,3 +194,16 @@ Luau로 부딪혀본 적은 없다는 걸 핸드오버 점검 중 발견 — `16
|
|||
- `17`은 전부 PASS가 기대값 — 특히 (B) 메타테이블 참조 동일성 assert가
|
||||
실패하면 M7 전체 설계("클래스별 런타임 코드 불필요")의 핵심 전제가
|
||||
무너지는 것이니 최우선으로 알려줄 것.
|
||||
- `18`은 1번 섹션이 true/true, 2번 섹션이 false/false로 나와야 기대값 —
|
||||
둘 중 하나라도 다르면(특히 `warn`이 뜨면) `relate-plan.md` "위험한 패턴"
|
||||
절과 `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` GC 설계
|
||||
전체를 최우선으로 재검토해야 함(Slot GC 안전성의 유일한 근거였음).
|
||||
- `19`는 전부 PASS가 기대값 — A 섹션이 FAIL이면 `tag-plan.md`의 참조
|
||||
카운트 알고리즘 자체를, C 섹션이 FAIL이면(특히 "같은 owner 재클레임은
|
||||
no-op"이 안 되면) `slot-plan.md`의 `elementOwner` 설계를 최우선으로
|
||||
재검토할 것 — C가 깨지면 재귀 재emit마다 마운트된 서브트리 전체가
|
||||
파괴됐다 재생성되는 파괴적 버그로 직결됨.
|
||||
- `20`도 전부 PASS가 기대값 — FAIL이 있으면 어느 케이스(특히 delta 부호가
|
||||
바뀌는 경계값)인지와 최종 배열/제거분이 어떻게 달랐는지 그대로 알려줄
|
||||
것, `Slot:Splice`의 shift 방향/양 계산에 off-by-one이 있다는 뜻이라
|
||||
`slot-plan.md` "확정" CRUD 표를 바로 고쳐야 함.
|
||||
|
|
|
|||
45
.claude/luau-test/gc-trigger-helper.server.luau
Normal file
45
.claude/luau-test/gc-trigger-helper.server.luau
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
--[[
|
||||
GC 완료를 간접 관찰하는 헬퍼 — Roblox Studio(실제 게임 스크립트) 환경 전용.
|
||||
|
||||
배경: Roblox는 `collectgarbage()`를 스크립트에 노출하지 않음(순수 `luau`
|
||||
CLI/`lune`과 다른 점 — `07-relate-weak-table-gc.luau`는 그쪽 환경에서
|
||||
`collectgarbage()`를 직접 부름). 그래서 예전엔 "Studio에서는 GC 타이밍
|
||||
검증 자체가 불가능하다"고 봤는데, incremental GC가 "충분한 할당 압력 +
|
||||
충분한 시간"이 주어지면 결국 한 사이클을 완료한다는 성질을 이용하면
|
||||
간접적으로 관찰 가능함 — 죽었어야 할 객체를 weak-value 테이블(canary)에
|
||||
넣어두고 그게 사라지는 시점을 기다리면 됨. 2026-08-13 사용자가 gcconn
|
||||
트릭 검증 중 직접 확인(`audit/gcconn-trick-verification.md` 참고).
|
||||
|
||||
주의:
|
||||
- 정확한 트리거가 아니라 **간접 관찰**임 — "이 시점에 정확히 GC가
|
||||
끝났다"를 보장하지 않음, "이 루프가 끝났다면 적어도 GC 사이클 한 번은
|
||||
지나갔다" 정도의 신호로만 쓸 것.
|
||||
- `task.wait`가 필요해서 Roblox 런타임(Studio/게임) 전용 — 순수 `luau`
|
||||
CLI엔 `task` 라이브러리가 없음(그쪽은 그냥 `collectgarbage()`를 직접
|
||||
부르면 됨, `07` 참고).
|
||||
- 실행 시간이 김(관찰 대상 크기/환경에 따라 GC 사이클 하나당 수 초~
|
||||
수십 초) — 다른 Studio 스파이크(`10` 등)에서 GC 완료를 기다려야 할
|
||||
때 아래 `waitForGC`를 그대로 복붙해서 쓰면 됨.
|
||||
|
||||
사용 예:
|
||||
local weak = setmetatable({}, {__mode = "v"})
|
||||
weak[1] = someValueExpectedToDie
|
||||
-- ... someValueExpectedToDie에 대한 다른 참조를 전부 없앤 뒤 ...
|
||||
waitForGC("someValue 해제 대기")
|
||||
assert(weak[1] == nil, "여전히 살아있음 — GC 안 됐거나 다른 곳에서 참조 중")
|
||||
]]
|
||||
|
||||
local function waitForGC(label: string?)
|
||||
print(`[GC] {label or "GC"} 대기 시작`)
|
||||
local canary = setmetatable({ {} }, { __mode = "v" }) -- 이 canary 하나만 참조하는 빈 테이블
|
||||
local epoch = 0
|
||||
while canary[1] do
|
||||
task.wait(0.1)
|
||||
table.create(5000, true) -- 할당 압력 생성 — incremental GC 진행을 강제
|
||||
epoch += 1
|
||||
if epoch % 100 == 0 then
|
||||
print(`[GC] {label or "GC"} 대기 중: {epoch}`)
|
||||
end
|
||||
end
|
||||
print(`[GC] {label or "GC"} 완료로 추정(epoch ~{epoch})`)
|
||||
end
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# 2026-08-13 첫 번째 세션 — gcconn 트릭 부분 실측, `Relate` 상호 순환 스파이크 신규, README 동기화
|
||||
|
||||
## 배경
|
||||
|
||||
사용자가 `base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 기대는
|
||||
"gcconn 트릭"의 핵심 가정 둘(ClassName PropertyChangedSignal 미발화,
|
||||
`Connection.Connected`가 `Destroy()` 시 GC 없이 동기적으로 `false`가 됨)을
|
||||
Roblox Studio에서 직접 실측하는 저수준 스크립트를 짜서 돌려봄 —
|
||||
`luau-test/10-roblox-studio-checks.server.luau`의 공식 스크립트는 아니고,
|
||||
같은 두 가정만 따로 떼어 검증한 자작 스크립트. 출력 로그를 보여주며 "이걸로
|
||||
`luau-test/README.md`가 '심각한 발견'이라고 못 박아둔 위험이 해소됐다고 볼
|
||||
수 있는지" 확인 요청.
|
||||
|
||||
## 1단계 — 읽기 전용 검토(쓰기 금지 지시)
|
||||
|
||||
당시 다른 에이전트가 작업 중이라는 안내가 있어 쓰기 없이 결론만 냄:
|
||||
스크립트/로직 자체는 정확했고, 두 핵심 가정(신호 미발화, Connected 즉시
|
||||
전환) 모두 확인됨 — 하지만 공식 `10` 파일이 커버하는 나머지(A-1/A-2
|
||||
`canBound`/`unbindLifetime` 이중 바인딩 게이트, Part B Attribute Instance
|
||||
참조, Part C CollectionService 왕복)는 이 자작 스크립트가 아예 안 건드려서
|
||||
"완전 해소"로 볼 수 없다는 게 결론 — 부분 확인.
|
||||
|
||||
## 2단계 — 문서화 요청
|
||||
|
||||
사용자가 이어서: (1) 해소된 부분만 정리해 `.claude/audit/` 같은 폴더에
|
||||
남기고, (2) 실측에 쓴 "GC 강제 트리거" 기법(canary weak table + 할당 압력
|
||||
+ `task.wait` 폴링)도 재사용 가능하게 문서화하고, (3) 그 김에 코퍼스 전체를
|
||||
훑어 luau-test에 더 필요한 게 있는지/stale한 게 있는지 보고 적절히
|
||||
갱신해달라고 요청.
|
||||
|
||||
## 한 일
|
||||
|
||||
**`.claude/audit/` 신설** — `gcconn-trick-verification.md` 작성: 확인된
|
||||
것 4개(신호 미발화, 연결 살아있는 동안 클로저 캡처값 GC 안 됨, Destroy 직후
|
||||
`Connected` 동기적 전환, Destroy+GC 후 클로저 캡처값 실제 수거), 아직 확인
|
||||
안 된 것(A-1/A-2, Part B/C, `inst` 자체를 `__mode="k"` weak key로 쓰는
|
||||
경로) 명확히 구분.
|
||||
|
||||
**GC 트리거 기법 문서화** — `luau-test/gc-trigger-helper.server.luau`
|
||||
신설(`waitForGC()` 복붙용 스니펫). 부수적으로 `07-relate-weak-table-gc.luau`의
|
||||
"Studio에서는 GC 타이밍 검증 자체가 불가능"이라던 서술이 이번 발견으로
|
||||
틀렸음을 확인 — `collectgarbage()` API가 없다는 것만 맞고, 간접 관찰은
|
||||
가능하다고 정정.
|
||||
|
||||
**`18-relate-mutual-cycle-gc.luau` 신규** — 코퍼스를 다시 훑던 중,
|
||||
`relate-plan.md`의 "두 `Relate` 상호 강참조 순환은 ephemeron 없이는 GC가
|
||||
안 풂"이라는 주장(Slot의 `kSlotMap`/`slotOwner` GC 수정 사례 전체의 유일한
|
||||
근거)이 지금까지 **공식 문서 인용으로만** 뒷받침돼 있었고 실제 Luau로
|
||||
재현해본 적이 없었던 갭을 발견 — 음성 대조군(순환 재현)/양성 대조군(한쪽을
|
||||
weak-value로 낮추면 풀리는지) 둘 다 넣은 순수 luau CLI 스파이크로 작성.
|
||||
|
||||
**CLAUDE.md stale 항목 발견·수정** — "지금 할 일" 2번(용어 정리)이 여전히
|
||||
`State`를 "1순위, 위험도 높음, open"으로 나열하고 있었는데, `question.md`
|
||||
확인 결과 `State`는 이미 2026-08-12 스무 번째 세션에 확정(현재 이름 유지)돼
|
||||
있었음 — `question.md`는 정확했고 CLAUDE.md의 압축 요약만 안 따라감. 실제
|
||||
열려있는 항목 목록(`DI`→`D`, `Slot`, `canExecute`, `Brand`, `Tag`류,
|
||||
`Attribute`/`AttributeKey`)으로 정정.
|
||||
|
||||
**서브에이전트에 위임한 코퍼스 전체 스윕** — 세션 8~21(전부 2026-08-12,
|
||||
`retract는 항상 불림` 정정부터 `Compute` 네이밍 근거까지)이 지난 마지막
|
||||
전체 감사(세션 16) 이후 `.claude/README.md`의 요약 테이블에 제대로
|
||||
반영됐는지, 그리고 이 기간에 생긴 새 메커니즘 중 luau-test 커버리지가
|
||||
빠진 게 있는지 조사 위임. 결과:
|
||||
|
||||
- `base/`/`research/` 문서 **본문 자체는 이미 전부 최신**이었음 — 갭은
|
||||
전적으로 `.claude/README.md`의 요약 테이블(색인 레이어)에만 있었음.
|
||||
`bind-system-plan.md`/`slot-plan.md`/`tag-plan.md`/`modifier-plan.md`/
|
||||
`architecture.md`/`framework-comparison-findings.md`/
|
||||
`operator-sugar-plan.md`/`pre-implementation-audit.md` 8개 행에
|
||||
세션 12~21 변경사항을 인용 마커로 보강.
|
||||
- `attribute-plan.md` 행은 단순 append가 아니라 실제 오류 수정 — 세션 11의
|
||||
중간 단계(`v==nil` 가드)를 "현재 상태"인 것처럼 적어뒀는데, 세션 16이
|
||||
이미 이걸 완전히 뒤집어(retract 완전 no-op) 낡은 서술이 됐던 것.
|
||||
- **`19-ownership-refcount-relate-patterns.luau`**,
|
||||
**`20-slot-splice-index-arithmetic.luau`** 신규 추가 — 세션 8~21에서
|
||||
생긴 세 소유권/참조카운트 알고리즘(Tag `kTagMap`/`tagNameMap`, Attribute
|
||||
`rawNew`+`owners`, Slot `elementOwner`)과 `Slot:Splice`의 시프트 산술이
|
||||
03/04/11/18과는 다른 새 알고리즘 모양이라 실측 가치가 있다고 판단해
|
||||
작성. Ref/Slot의 retract-via-`Relate`-diff(세션 8/9)는 기존 03/04와 같은
|
||||
메커니즘 급이라 스파이크 불필요로 판단, 추가 안 함.
|
||||
- Part C(세션 17~21의 `pre-implementation-audit.md`/
|
||||
`framework-comparison-findings.md`/`operator-sugar-plan.md`/
|
||||
`question.md`/`ROADMAP.md` 개별 파일 스팟체크)는 전부 이미 정확해서
|
||||
추가 수정 없음.
|
||||
|
||||
## 결과물
|
||||
|
||||
- 신규: `.claude/audit/gcconn-trick-verification.md`,
|
||||
`luau-test/gc-trigger-helper.server.luau`,
|
||||
`luau-test/18-relate-mutual-cycle-gc.luau`,
|
||||
`luau-test/19-ownership-refcount-relate-patterns.luau`,
|
||||
`luau-test/20-slot-splice-index-arithmetic.luau`
|
||||
- 수정: `luau-test/07-relate-weak-table-gc.luau`(docstring 정정),
|
||||
`luau-test/README.md`(테이블/공통 유틸리티 절/갱신이력 8~9차/결과 확인
|
||||
체크리스트), `base/lifecycle-pattern.md`(gcconn 구현 스케치에 실측 각주),
|
||||
`.claude/README.md`(`audit/` 행 신설, `base`/`research` 테이블 9개 행
|
||||
동기화), `CLAUDE.md`("지금 할 일" 1/2번)
|
||||
|
||||
## 남은 것
|
||||
|
||||
- 공식 `luau-test/10` 파일로 A-1/A-2/Part B/Part C 마저 확인 필요(`10`의
|
||||
A 섹션 앞부분만 부분 확인된 상태).
|
||||
- `01`~`06`, `08`, `09`, `11`~`20`(총 18개 중 `10` 제외 전부) — 여전히
|
||||
사용자가 실제로 안 돌려봄, M0 착수 전 최우선 게이트.
|
||||
40
CLAUDE.md
40
CLAUDE.md
|
|
@ -114,18 +114,27 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은
|
||||
2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부
|
||||
해소되어 **11개 전원 완료** — M0 착수 전 남은 유일한 게이트는:
|
||||
- **`.claude/luau-test/`(2026-08-09 신설, 2026-08-12 열일곱 번째 세션에
|
||||
16/17 추가로 총 17개) 스파이크 결과** — 아직 사용자가
|
||||
`luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 안 돌려봄, 결과가
|
||||
- **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개 —
|
||||
2026-08-12 열일곱 번째 세션에 16/17 추가, 2026-08-13에 세션 8~21이
|
||||
만든 커버리지 갭을 메우는 18/19/20 추가) 스파이크 결과** —
|
||||
**[2026-08-13] `10`의 A 섹션 일부(ClassName 신호 미발화, Destroy 시
|
||||
Connection.Connected 즉시 전환)가 처음으로 부분 확인됨**, 결과는
|
||||
`.claude/audit/gcconn-trick-verification.md`. 나머지 19개 전체(신규
|
||||
`18`/`19`/`20` 포함) + `10`의 남은 부분(A-1/A-2/B/C)은 여전히 사용자가
|
||||
`luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 안 돌려봄 — 결과가
|
||||
나오면 그것부터 반영할 것(`luau-test/README.md`가 파일별로 뭘 우선
|
||||
확인해야 하는지 이미 적어둠). 걸리는 게 있으면 `base/` 문서부터 고치고,
|
||||
없으면 그대로 M0 실제 코드 작성에 재사용. 설계 자체는 더 이상 막힌
|
||||
게 없음 — `.claude/question.md` 2번이 최신 상태.
|
||||
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
|
||||
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
|
||||
stale해지는 패턴이 반복됐어서). 아직 열려있는 것만 짚으면: `State`(1순위,
|
||||
위험도 높음 — 업계 통념("쓸 수 있는 로컬 상태")과 반대 의미라 오해 위험),
|
||||
`DI`→`D`, `Slot`, `canExecute`→`isAlive`, `Brand`.
|
||||
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는
|
||||
2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
|
||||
목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
|
||||
— 아직 진짜로 열려있는 것만 짚으면: `DI`→`D`(1순위), `Slot`(2순위),
|
||||
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지
|
||||
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/
|
||||
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
|
||||
3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만
|
||||
필요, 구현 착수를 막지 않음.
|
||||
4. **[백로그]** 범용 렌더 디버깅 도구 `quad-mock`(Tween mock 등 동적 동작
|
||||
|
|
@ -733,3 +742,22 @@ Vue/Svelte는 lazy인데도 `computed`를 쓰지만, quad 자기 코퍼스 안
|
|||
`Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"
|
||||
관례를 선점해뒀어서 lazy한 State에 재사용하면 자기 관례와 충돌 — `Compute`가
|
||||
더 정확하다는 데 동의, `bind-system-plan.md`에 근거 추가.
|
||||
|
||||
**2026-08-13 첫 번째 세션 — gcconn 트릭 부분 실측, `Relate` 상호 순환
|
||||
스파이크 신규, README 동기화**
|
||||
(`session/2026-08-13-01-gcconn-audit-relate-cycle-spike-readme-sync.md`)
|
||||
사용자가 Studio에서 gcconn 트릭의 핵심 가정(ClassName 신호 미발화, Destroy
|
||||
시 `Connected` 즉시 전환) 둘을 자작 스크립트로 실측 — 결과를
|
||||
`.claude/audit/gcconn-trick-verification.md`에 정리(부분 확인, `luau-test/10`의
|
||||
A-1/A-2/B/C는 미해소로 명확히 구분). GC 강제 트리거 기법을
|
||||
`gc-trigger-helper.server.luau`로 문서화하면서 `07`의 "Studio에서 GC 검증
|
||||
불가" stale 서술도 정정. 코퍼스를 다시 훑다가 `relate-plan.md`의 "두
|
||||
`Relate` 상호 순환은 ephemeron 없이 GC 안 됨" 주장이 공식 문서 인용으로만
|
||||
뒷받침돼 실측된 적 없었던 갭을 발견해 `18` 신규 작성, CLAUDE.md 자신의
|
||||
"지금 할 일"이 이미 해소된 `State` 용어 논쟁을 stale하게 open으로 남겨뒀던
|
||||
것도 발견·수정. 서브에이전트에 위임한 코퍼스 스윕으로 세션 8~21(마지막
|
||||
전체 감사인 세션 16 이후) 변경사항이 `.claude/README.md` 요약 테이블에
|
||||
안 반영돼 있던 8개 행 동기화(base/research 문서 본문 자체는 이미 최신,
|
||||
색인 레이어만 밀렸던 것) + `attribute-plan.md` 행의 실제 오류(폐기된
|
||||
중간 단계 서술) 수정 + 새 소유권/참조카운트 알고리즘(Tag/Attribute/Slot)과
|
||||
`Slot:Splice` 산술을 커버하는 `19`/`20` 스파이크 추가(총 20개).
|
||||
|
|
|
|||
Loading…
Reference in a new issue