qa: 구현 전 QA 2라운드 — recompute 손 트레이싱으로 RC-1 발견·해결
`:List` reconcile과 dispatch-core-plan.md의 recompute를 실제로 손으로 실행해보다 RC-1(배열 위치가 순차 등록되는 동안 아직 안 채워진 자리를 읽어 산술 에러가 나는 크래시, 정적 자식 2개짜리 Frame도 재현)을 찾았다. 같은 세션 후속 대화에서 사용자가 제시한 Blocker 재사용 게이팅 설계로 해결 — owner별 전용 Blocker가 배치 등록 동안 recompute를 막고, setOffsetSource는 등록 즉시 앞선 형제 합을 직접 계산하며, attachSlot의 호출 순서(setOffsetSource→실체화→setLength→물리 마운트)도 바로잡고 코루틴 yield 금지 불변식을 명문화했다. Blocker에는 IsOn()/ OffWithoutEmit()이 새로 생겼다. /code-review high가 이 diff에서 10건을 더 찾아 반영 — D-7 재역전과의 정합성, filter/nil 재역전 반영 누락, PreRef 가드의 typeof(k) 누락, New()→Quad() 리네임 전파 누락, M6/M8 마일스톤 오기 등. quad-doc-auditor 감사 루프도 여러 라운드 돌려 새 발견 0건까지 수렴시켰다. Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
d768e4cbc5
commit
a1f948ae34
15 changed files with 793 additions and 83 deletions
File diff suppressed because one or more lines are too long
|
|
@ -41,6 +41,45 @@
|
|||
- 지금 유효한 설계는 `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트
|
||||
네이밍 인체공학" 절이 소스.
|
||||
|
||||
## [해소됨, 2026-08-18 구현 전 QA 2라운드 후속] `RC-1` — `recompute` 트리거 모델 재설계
|
||||
|
||||
`base/dispatch-core-plan.md`의 `recompute`가 배열 위치를 순차 등록하는
|
||||
동안(`Frame{A,B}`처럼 정적 자식 2개짜리도) 아직 등록 안 된 자리를 `nil`로
|
||||
읽어 산술 에러를 내던 크래시(`qa-request/pre-implementation-qa-round2.md`의
|
||||
"RC-1" 절에서 손 트레이싱으로 발견) — 같은 날 후속 세션에서 사용자가
|
||||
Blocker 재사용 설계를 직접 제시해 해결됨.
|
||||
|
||||
- **핵심**: owner(물리 `inst` 또는 Slot)마다 `Relate`로 들고 있는 전용
|
||||
`Blocker`를 배치(`Dispatch.drive`의 배열 파트 순회, `attachSlot`의 자기
|
||||
`_elements` flush) 시작 시 `On`, `setLength`의 Observer 콜백(등록 즉시
|
||||
1회 실행 포함)이 `blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가
|
||||
끝나면 `blocker:OffWithoutEmit()` + 명시적 `recompute` 딱 1회.
|
||||
`setOffsetSource`는 등록되는 그 자리에서 앞선 형제들의 길이 합을 직접
|
||||
계산해 즉시 `:Set`해서(recompute를 안 기다림) 배치 도중 `:List`가
|
||||
실체화되며 옛 offset을 읽는 문제도 같이 없앰.
|
||||
- **Blocker에 신설된 API**: `IsOn()`(`IsBlocked` 조회 얇은 래퍼),
|
||||
`OffWithoutEmit()`(끄되 gated state의 대기 emit은 흘려보내지 않음,
|
||||
`HasBlockedEmit`은 그대로 리셋) — `state:Block()`을 거치지 않고 직접
|
||||
쓰는 첫 사례. 처음 요청됐던 `HasBlocked`(Blocker 자신의 새 최상위
|
||||
플래그)는 논의 중 불필요함이 확인돼 신설 안 함.
|
||||
- **중첩 Slot마다 별도 Blocker**(부모와 공유 금지, `blocker-plan.md`의
|
||||
재진입 미지원 규칙 그대로), **런타임 단건 `slot:Add()`는 게이팅 불필요**
|
||||
(이미 안정된 앞선 position만 참조하므로 무관) — 둘 다 사용자가 직접
|
||||
확인.
|
||||
- **후속 정정 — `attachSlot` 호출 순서**: 기존 의사코드는 `setLength`를
|
||||
먼저 불렀는데, "호출 순서는 `setOffsetSource` → `setLength`" 일반
|
||||
규칙과 어긋나 있었음이 드러남(RC-1로 `setOffsetSource`가 즉시 계산을
|
||||
하게 되며 순서가 겉으로 드러났기 때문) — Slot의 진짜 `.Length`는
|
||||
`activateList` 실체화 뒤에야 확정되므로 `setOffsetSource` → 실체화 →
|
||||
`setLength` → 물리 마운트 순으로 바로잡음. 같은 논의에서 **코루틴 yield
|
||||
금지 불변식**도 확정(`Dispatch.process`/`attachSlot` 호출 체인 도중
|
||||
yield는 UB).
|
||||
- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게
|
||||
만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절,
|
||||
`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례"
|
||||
절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은
|
||||
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절.
|
||||
|
||||
---
|
||||
|
||||
# 확인/결정 필요 목록
|
||||
|
|
|
|||
|
|
@ -98,9 +98,11 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
|
|||
정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도
|
||||
프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은
|
||||
`quad-roblox`가 담당.
|
||||
13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox
|
||||
프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()`
|
||||
추가. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라
|
||||
13. **모듈은 기본 싱글톤, `Quad()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox
|
||||
프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `Quad()`
|
||||
추가(**[정정, 2026-08-18 `/code-review high`] 이름은 아래 배너대로
|
||||
`New()`가 아니라 `Quad()`가 확정 — 이 문장도 그 이름으로 통일**).
|
||||
**메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라
|
||||
기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)`로
|
||||
격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가
|
||||
`BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새 `BaseModule`
|
||||
|
|
|
|||
|
|
@ -112,9 +112,13 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들
|
|||
아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는
|
||||
서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가
|
||||
초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만
|
||||
두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 실은
|
||||
열려있던 게 아니라 자연히 풀림 — `New()`가 생기면 각 인스턴스가 별도
|
||||
테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요.
|
||||
두면 됨. 모듈 스코핑(`Quad()`, `base/architecture.md` 13번)과의 관계도
|
||||
실은 열려있던 게 아니라 자연히 풀림 — `Quad()`가 생기면 각 인스턴스가
|
||||
별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨(**[한정,
|
||||
2026-08-18 `/code-review high` — `architecture.md` 13번의 정정과 맞춤]**
|
||||
단 "자동으로"는 아님 — module-level state를 참조하는 코드들이 모듈
|
||||
인스턴스를 인자로 받도록 손을 봐야 하는 건 architecture.md 13번의 정정
|
||||
그대로, 여기서 반복 안 함).
|
||||
|
||||
## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,15 @@ debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸
|
|||
Blocker() -> blocker -- 생성자
|
||||
blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함
|
||||
blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된
|
||||
-- onunblock 핸들 전부 실행(순서 무관, idempotent)
|
||||
-- onunblock 핸들 전부 실행(emit=true로, 순서 무관, idempotent)
|
||||
blocker:OffWithoutEmit() -> self -- [2026-08-18 신설] IsBlocked = false로 먼저 설정, 그 다음
|
||||
-- 등록된 onunblock 핸들 전부 실행(emit=false로) — 각 핸들이
|
||||
-- 자기 HasBlockedEmit은 그대로 리셋하되 실제 emit은 건너뜀.
|
||||
-- `Off()`와 내부 로직을 공유(아래 "onunblock 핸들" 참고),
|
||||
-- 차이는 넘기는 emit 플래그 하나뿐.
|
||||
blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 그대로 반환하는
|
||||
-- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래
|
||||
-- "이름 확정" 참고), 호출부 가독성만을 위한 추가.
|
||||
|
||||
state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에
|
||||
-- 처음 블록될 때가 아니라) onunblock 핸들을
|
||||
|
|
@ -49,9 +57,14 @@ gated state의 동작:
|
|||
- 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도.
|
||||
- `blocker.IsBlocked`이면: 전파 안 하고 `HasBlockedEmit = true`만 세팅.
|
||||
- `blocker.IsBlocked`가 아니면: 평소처럼 그냥 전파(투명하게 통과).
|
||||
- `blocker:Off()`가 실행하는 onunblock 핸들은: `HasBlockedEmit`을 확인해
|
||||
true면 그제서야 정확히 1회 전파(emit)하고 플래그를 리셋. 이미 false면
|
||||
아무 것도 안 함(idempotent).
|
||||
- **onunblock 핸들은 이제 `emit: boolean` 인자를 받는다**(`blocker:Off()`/
|
||||
`:OffWithoutEmit()`가 공유하는 내부 실행 경로, 2026-08-18 신설) —
|
||||
`HasBlockedEmit`을 확인해 true면 `emit`이 참일 때만 그제서야 정확히
|
||||
1회 전파(emit)하고, `emit`이 거짓이면 전파 없이 플래그만 리셋. 이미
|
||||
`HasBlockedEmit`이 false면 `emit` 값과 무관하게 아무 것도 안 함
|
||||
(idempotent). 즉 `Off()`는 "밀린 전파를 흘려보내며 끈다",
|
||||
`OffWithoutEmit()`은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기
|
||||
상태(`HasBlockedEmit`)는 항상 깨끗하게 리셋됨.
|
||||
|
||||
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도
|
||||
누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서
|
||||
|
|
@ -77,6 +90,28 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
|
|||
가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번
|
||||
바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다.
|
||||
|
||||
## `state:Block()` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설)
|
||||
|
||||
지금까지 위 예시는 전부 `state:Block(blocker)`로 만든 **gated state**를
|
||||
경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md`의
|
||||
"Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩
|
||||
순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를
|
||||
고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다
|
||||
— 콜백 안에서 `blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는
|
||||
방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end`
|
||||
형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지
|
||||
않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 —
|
||||
`blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두
|
||||
메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기
|
||||
위해 `OffWithoutEmit()`을 쓴다**("이 배치가 끝나면 무엇이든 자동으로
|
||||
흘려보내지 말고, 호출자가 직접 정확히 한 번 후속 작업을 한다"는 의도
|
||||
표현). 상세 메커니즘·`Dispatch.setLength`/`setOffsetSource`가 이 Blocker를
|
||||
어떻게 만들고 어디에 저장하는지는 `base/dispatch-core-plan.md`의 "배치
|
||||
등록을 안전하게 만드는 Blocker 게이팅" 절이 소스 — 여기서 반복하지 않음.
|
||||
**재진입(네스팅) 미지원 규칙은 이 용례에도 그대로 적용** — 중첩된 owner
|
||||
(예: 부모 Slot 안의 자식 Slot)마다 각자 자기 owner 키로 별도 Blocker를
|
||||
새로 만들어야 하고, 부모 Blocker를 재사용/전달하면 안 됨(아래 "재진입" 절).
|
||||
|
||||
## 이름 확정
|
||||
|
||||
- 클래스: `Blocker` — `Observer`/`Modifier`/`Ref`와 같은 명사-행위자
|
||||
|
|
@ -89,6 +124,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
|
|||
- 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`**
|
||||
(gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌).
|
||||
- 메소드: `state:Block(blocker) -> state`.
|
||||
- **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는
|
||||
얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들"
|
||||
참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함.
|
||||
HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 즉 `IsBlocked`/
|
||||
`HasBlockedEmit` 필드는 그대로 유지하고, 별도 `HasBlocked`(Blocker
|
||||
자신의 새 최상위 플래그)는 **신설하지 않는다** — `OffWithoutEmit()`이
|
||||
각 gated state의 기존 `HasBlockedEmit`을 그대로 리셋해주는 것으로
|
||||
충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의
|
||||
과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md`
|
||||
"RC-1" 절에 논의 경위 기록).
|
||||
|
||||
## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수
|
||||
|
||||
|
|
@ -104,7 +149,22 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
|
|||
문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면
|
||||
조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐.
|
||||
|
||||
## 상태: 핵심 메커니즘+이름 확정. [2026-08-07 기준] 남은 건 문서화뿐
|
||||
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
|
||||
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
|
||||
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`을
|
||||
재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** —
|
||||
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도
|
||||
Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의
|
||||
`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려
|
||||
부모의 나머지 등록이 게이팅을 잃는 사고가 났을 것 — 바로 위 문단이
|
||||
경고하는 실패 모드의 구체 사례.
|
||||
|
||||
## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐
|
||||
|
||||
**[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과
|
||||
`state:Block()` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API
|
||||
확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과
|
||||
메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신.
|
||||
|
||||
`quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는
|
||||
게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용).
|
||||
|
|
|
|||
|
|
@ -127,10 +127,20 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
**이걸로 `module-lifecycle-plan.md`의 "열린 질문이었던 것 — 전부
|
||||
해소됨" 절에 있는 "provider가 아직 주입 안 된 상태에서 dispatch가
|
||||
호출되면?" 케이스(`pre-implementation-audit.md`
|
||||
1-4)도 별도 분기 없이 자동으로 해소됨** — provider 미주입 상태는
|
||||
1-4)도 별도 분기 없이 자동으로 해소됨** — **backend가 직접 소유하는
|
||||
핸들러(`Property`/`Event`/`Slot`류)에 한해** provider 미주입 상태는
|
||||
결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로
|
||||
"매치 실패"와 정확히 같은 경로로 수렴함. 오타 키/미지원 조합/provider
|
||||
미주입을 서로 다른 에러 종류로 구분할 필요가 없음.
|
||||
"매치 실패"와 정확히 같은 경로로 수렴함. **[한정, 2026-08-18 `/code-review
|
||||
high` — `D-7` 재역전과의 정합성]** `Tag`/`Attribute`처럼 **base가
|
||||
Fallback Handler를 자기 로드 시점에 스스로 등록하는 것**(위 문단,
|
||||
"base가 소유하는 핸들러와 주입되는 엔진 op" 절)은 이 일반화의 예외다 —
|
||||
백엔드가 하나도 없어도 그 Fallback Handler는 이미 레지스트리에 있으므로
|
||||
**매치는 되고**, 실패는 "매치 실패" 에러가 아니라 그 자리에서 실행되는
|
||||
주입 op 스텁의 명시적 에러(`addTag가 구현되지 않음...` 류)로 남
|
||||
— "provider 미주입"과 "매치 실패"가 **에러 경로 자체는 다르지만 둘
|
||||
다 명확한 에러로 수렴한다"**는 결론은 안 바뀜, 다만 오타 키/미지원
|
||||
조합과 provider 미주입을 구분할 필요가 없다는 문장은 backend 소유
|
||||
핸들러에만 해당한다.
|
||||
- **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 +
|
||||
전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로
|
||||
sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은
|
||||
|
|
@ -459,6 +469,13 @@ end
|
|||
`PostRef`를 fire하는 짧은 루프. 둘 다 배열 재순회가 아니라 pre-pass
|
||||
하나 + 실제 `PostRef` 개수만큼의 목록 순회라 비용이 작음 — 상세는
|
||||
`base/ref-plan.md`의 "`PostRef`" 절.
|
||||
**[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결] 배열 파트 순회
|
||||
전체를 `inst` 전용 `Blocker`로 감싼다** — 순회 시작 전에
|
||||
`Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, 배열 파트 순회가
|
||||
(pre-pass/post-pass 포함) 전부 끝나면 `:OffWithoutEmit()` 한 뒤
|
||||
`recompute(inst, bk)`를 명시적으로 1회 호출 — 상세 근거·`setLength`/
|
||||
`setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 아래 "배치 등록을
|
||||
안전하게 만드는 Blocker 게이팅" 절이 소스.
|
||||
**진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입
|
||||
후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을
|
||||
처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와
|
||||
|
|
@ -511,8 +528,9 @@ function NilHandler.process(inst, k, v, index)
|
|||
-- 이 자리는 아무것도 마운트하지 않는다 — 순서 계산에서 빠지도록 등록만 한다.
|
||||
-- 순서 주의: setOffsetSource가 먼저, setLength가 나중(아래 "해제(그 자리가
|
||||
-- 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절의 계약 —
|
||||
-- setLength가 끝에서 recompute를 돌리므로 반대로 하면 죽는 중인 서브트리의
|
||||
-- Source에 :Set()이 날아간다). [2026-08-18 감사에서 순서 정정]
|
||||
-- setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
|
||||
-- 반대로 하면 죽는 중인 서브트리의 Source에 :Set()이 날아간다).
|
||||
-- [2026-08-18 감사에서 순서 정정]
|
||||
Dispatch.setOffsetSource(inst, k, None)
|
||||
Dispatch.setLength(inst, k, 0)
|
||||
return function() end
|
||||
|
|
@ -581,16 +599,20 @@ end
|
|||
전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은
|
||||
더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서
|
||||
소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`).
|
||||
- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히
|
||||
풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는
|
||||
- **모듈 재생성(`Quad()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히
|
||||
풀림.**(**[정정, 2026-08-18 `/code-review high`] 헤딩이 옛 이름 `New()`를
|
||||
쓰고 있었음 — `architecture.md` 13번 항목과 같은 이유로 `Quad()`로
|
||||
통일, 아래 "[한정]" 문단이 이미 정확한 이름을 씀**) v1처럼 `require`를
|
||||
감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는
|
||||
방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며
|
||||
기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가
|
||||
`BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을
|
||||
그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에
|
||||
딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된
|
||||
것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가
|
||||
것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`Quad()`가
|
||||
생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로
|
||||
스코핑됨, 재설계 불필요"). 다중 인스턴스화가 실제로 생기면 그 시점에
|
||||
스코핑됨" — 단 아래 "[한정]" 문단대로 코드 손질은 필요, 재설계까지는
|
||||
불필요). 다중 인스턴스화가 실제로 생기면 그 시점에
|
||||
BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히
|
||||
같이 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스
|
||||
테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음.
|
||||
|
|
@ -1084,6 +1106,16 @@ end
|
|||
중간 노드가 `inst`에 직접 부작용을 냈다면 그 흔적을 지울 주체가 없어짐.
|
||||
조건부로만 재위임하는 핸들러를 만들면 재위임을 건너뛰는 자리에서
|
||||
`Dispatch.retractFrom(inst, k, index + 1)`로 아래를 직접 정리할 것.
|
||||
|
||||
**9. `process` 안에서(또는 `process`가 부르는 컴포넌트 함수/`updateFn`
|
||||
안에서) 코루틴 yield 금지(2026-08-18 신설, `/code-review high`로 이
|
||||
불변식이 "Length/Offset" 절에만 묻혀 있던 걸 발견해 여기로도 끌어올림).**
|
||||
아래 "Length/Offset" 절의 배치 게이팅(`Blocker`)이 "position이 항상
|
||||
순서대로, 다른 코드가 끼어들 틈 없이 동기로 처리된다"는 전제 위에
|
||||
서 있음 — 이 체인 도중 yield가 끼면 같은 owner의 `Blocker`를 다른
|
||||
코드가 그 사이에 건드릴 수 있어 게이팅 순서 보장이 깨짐. 상세 근거는
|
||||
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절.
|
||||
|
||||
### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션)
|
||||
|
||||
**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문,
|
||||
|
|
@ -1142,8 +1174,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
|||
`State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state<Slot>`
|
||||
교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출.
|
||||
- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source<number>`를
|
||||
**스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만
|
||||
하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우
|
||||
**스스로 만들어서** 등록. **[2026-08-18 구현 전 QA 2라운드 후속 —
|
||||
`RC-1` 해결]** 예전엔 "Dispatch는 그냥 레지스트리에 넣어두기만 하고
|
||||
`recompute`가 그 자리에 값을 `:Set()`한다"였는데, 이제 **등록되는 그
|
||||
자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 즉시
|
||||
`:Set()`한다**(아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절의
|
||||
"`setOffsetSource`의 즉시 계산" 참고) — `recompute`는 이후 값이 바뀔 때
|
||||
전체를 다시 계산하는 역할로 남는다. Slot이 매치되는 경우
|
||||
이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래
|
||||
참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이
|
||||
등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정,
|
||||
|
|
@ -1189,8 +1226,9 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
|||
**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`
|
||||
→ `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).**
|
||||
별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가
|
||||
반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저
|
||||
부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는
|
||||
반대면 위험함**: `setLength`가 끝에서 `gatedRecompute`를 경유해(배치
|
||||
게이팅 중이 아니면) `recompute`를 돌리므로, 먼저 부르면 그 `recompute`가
|
||||
아직 남아있는 옛 `Source`(지금 막 떼어내는
|
||||
서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이
|
||||
캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`의
|
||||
`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이
|
||||
|
|
@ -1229,6 +1267,14 @@ lazy 생성.
|
|||
|
||||
**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**:
|
||||
|
||||
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속] 아래 의사코드를 배치
|
||||
등록 중 안전하지 않게 만들던 크래시(`RC-1`)는 해결됨 — 해법은 "배치
|
||||
등록을 안전하게 만드는 Blocker 게이팅" 절(바로 아래)이 소스, 여기
|
||||
`recompute` 자체의 코드는 안 바뀜(off-by-one 수정 버전 그대로). 바뀐
|
||||
건 **언제 호출되는가**뿐 — `setLength`/`setOffsetSource`가 새로 개입한다.
|
||||
트레이싱 경위·논의 원문은 `qa-request/pre-implementation-qa-round2.md`의
|
||||
"RC-1" 절.
|
||||
|
||||
**[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어
|
||||
있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한
|
||||
뒤 `offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한
|
||||
|
|
@ -1308,11 +1354,22 @@ vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것.
|
|||
일어나게 막음.
|
||||
|
||||
**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`),
|
||||
`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**:
|
||||
`:Subscribe()` 아님(2026-08-09 여섯 번째 세션).** **[재작성, 2026-08-18
|
||||
구현 전 QA 2라운드 후속 — `RC-1` 해결]** `setLength`는 더 이상 `recompute`를
|
||||
직접 부르지 않는다 — State든 상수든 항상 아래 `gatedRecompute` 하나를
|
||||
경유하고, 그 함수가 `blocker:IsOn()`을 확인해 배치 등록 중이면 건너뛴다
|
||||
(Observer의 "등록 즉시 1회 실행"으로 촉발되는 최초 호출도 예외 없이 이
|
||||
게이트를 통과한다 — 사용자: *"setLength 는 recompute 를 직접 수행하진
|
||||
않고, Observer 에서 recompute 를 수행해. 맨 처음 emit 에서도 blocker 가
|
||||
on 이면 무시하는식"*). `blocker`가 무엇이고 어디서 오는지는 바로 아래
|
||||
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절 참고 — 이 함수는 그
|
||||
Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지 않음,
|
||||
그건 호출하는 배치 쪽 책임):
|
||||
|
||||
```lua
|
||||
function Dispatch.setLength(inst, i, len)
|
||||
local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성
|
||||
function Dispatch.setLength(ownerKey, i, len)
|
||||
local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
|
||||
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
|
||||
|
||||
local oldObserver = bk.observers[i]
|
||||
if oldObserver then
|
||||
|
|
@ -1322,23 +1379,122 @@ function Dispatch.setLength(inst, i, len)
|
|||
|
||||
bk.lengthList[i] = len
|
||||
|
||||
if isState(len) then
|
||||
local observer = len:Observer(function()
|
||||
recompute(inst, bk)
|
||||
end)
|
||||
bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님
|
||||
bk.observers[i] = observer
|
||||
local function gatedRecompute()
|
||||
if not blocker:IsOn() then
|
||||
recompute(ownerKey, bk)
|
||||
end
|
||||
end
|
||||
|
||||
recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해)
|
||||
if isState(len) then
|
||||
local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨
|
||||
bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님
|
||||
bk.observers[i] = observer
|
||||
else
|
||||
gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
|
||||
본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때
|
||||
같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안
|
||||
끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native,
|
||||
`inst` 생명주기에 자동 귀속)를 충족.
|
||||
본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
|
||||
또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적
|
||||
`:Unsubscribe()`가 없으면 안 끊기므로 안 맞음. `bindLifetime`/
|
||||
`unbindLifetime`이 이미 이 요구(GC-native, `ownerKey` 생명주기에 자동
|
||||
귀속)를 충족.
|
||||
|
||||
### 배치 등록을 안전하게 만드는 Blocker 게이팅 (2026-08-18, `RC-1` 해결)
|
||||
|
||||
**문제 재확인**: `bk.N`(그 owner의 array part 크기)은 배치가 시작되는
|
||||
시점에 이미 정해져 있는데, `bk.lengthList[1..N]`은 각 position이 처리될
|
||||
때마다 하나씩 채워진다 — 순차 처리 도중에 `recompute`가 돌면 아직 안
|
||||
채워진 뒤쪽 position을 `nil`로 읽어 산술 에러가 난다(`Frame{A,B}`처럼
|
||||
정적 자식 2개짜리도 재현됨, 트레이싱 상세는
|
||||
`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절).
|
||||
|
||||
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
|
||||
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:
|
||||
|
||||
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기
|
||||
자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그
|
||||
owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작
|
||||
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고
|
||||
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
|
||||
직접 쓰는 두 번째 용례" 절 참고.
|
||||
2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는
|
||||
`gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉
|
||||
**배치 도중엔 `recompute`가 단 한 번도 안 돈다**, 그래서
|
||||
`bk.lengthList`의 빈 자리를 읽을 일 자체가 없다.
|
||||
3. **`setOffsetSource`는 그동안 손 놓고 있지 않는다 — 등록되는 그
|
||||
자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 `:Set`한다**
|
||||
(아래 "`setOffsetSource`의 즉시 계산" 참고). 배치가 항상 position을
|
||||
순서대로(1,2,...,N) 처리하므로, position `i`를 등록하는 시점엔 `1..i-1`이
|
||||
이미 전부 끝나 있어 이 합산이 항상 정확하다. **이게 "recompute를
|
||||
미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List`가
|
||||
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
|
||||
배치 중이라도 항상 최신값을 보게 됨.
|
||||
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는
|
||||
`attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을
|
||||
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
|
||||
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
|
||||
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
|
||||
재귀 케이스)도 같이 확정시킨다.
|
||||
|
||||
**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서
|
||||
`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면
|
||||
스킵 — 참여 안 하는 자리는 계산할 게 없음):
|
||||
|
||||
```lua
|
||||
function Dispatch.setOffsetSource(ownerKey, i, source)
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
bk.sourceList[i] = source
|
||||
if source ~= None then
|
||||
local sum = 0
|
||||
for j = 1, i - 1 do
|
||||
local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
end
|
||||
if source:Get() ~= sum then
|
||||
source:Set(sum)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
이건 `recompute`의 로직을 대체하는 게 아니라 **보완**한다 — 이 즉시
|
||||
계산은 "지금 막 등록되는 이 position의 초기값"만 맞춰줄 뿐이고, 이후 어느
|
||||
position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보다 뒤에
|
||||
있는 모든 position의 offset을 다시 계산해야 하므로 여전히 `recompute`의
|
||||
전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로).
|
||||
|
||||
**적용 지점 — `Dispatch.drive`와 `attachSlot`, 각각 자기 owner 키로
|
||||
별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
|
||||
둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위
|
||||
`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의**
|
||||
`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 —
|
||||
중첩된 Slot마다 그 Slot 자신의 owner 키로 **별도** Blocker를 새로 만듦,
|
||||
부모 Blocker 재사용 금지는 `base/blocker-plan.md`의 "재진입" 절 그대로).
|
||||
**런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 흔한 패턴은
|
||||
이 게이팅이 필요 없다** — 사용자 확인: *"그건 이미 마운트가 된
|
||||
이후라서 별 상관 없음... 새로운 개체가 뒤에 붙는 현상에서는 위
|
||||
요소들로 하여금 위치를 구하면 돼, 뒷 요소를 밀어내는게 아니라서,
|
||||
setLength 가 emit 되지 않는것에 영향 안 받고 수행 가능함"* — 이미
|
||||
마운트된 배치 밖에서 하나씩 추가되는 position은 그 앞의 모든 position이
|
||||
이미 안정적으로 등록돼 있어 `nil` 자리를 만들 여지가 없고, 그 owner의
|
||||
Blocker는 이미 `OffWithoutEmit()`으로 꺼진 채라 `gatedRecompute`가
|
||||
평소처럼 즉시 돈다.
|
||||
|
||||
**⚠️ 불변식 — `Dispatch.process`/`attachSlot` 호출 체인 도중에는 코루틴
|
||||
yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체가
|
||||
"position이 항상 1,2,...,N 순서대로, 다른 코드가 끼어들 틈 없이 동기로
|
||||
처리된다"는 전제 위에 서 있다 — 이 체인 도중 어딘가(컴포넌트 함수,
|
||||
`updateFn`, Handler 등) yield가 끼면, 아직 배치가 안 끝난 owner의 같은
|
||||
`Blocker`를 다른 코드가 그 사이에 건드릴 수 있어(예: 다른 이벤트 콜백이
|
||||
같은 owner에 `setLength`를 부르는 것) 배치 도중/직후의 게이팅 순서 보장이
|
||||
깨진다. 사용자: *"모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야
|
||||
할듯. 안 그럼 꼬이는 문제가 발생하지 않나 생각함"* — 웹 백엔드처럼
|
||||
`setLength`가 뒤섞이면 특히 골치 아파짐. 새 방어 로직을 넣는다는 뜻이
|
||||
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
|
||||
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
|
||||
|
||||
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
|
||||
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:
|
||||
|
|
|
|||
|
|
@ -174,10 +174,13 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
|
|||
백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기
|
||||
Handler를 추가로 등록할 수 있음 — 상세는
|
||||
`base/dispatch-core-plan.md`의 같은 절. **중복 호출
|
||||
가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로
|
||||
가드/`Quad()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로
|
||||
재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 —
|
||||
바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가
|
||||
바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `Quad()`가
|
||||
생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로
|
||||
스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도
|
||||
스코핑됨(**[한정, 2026-08-18 `/code-review high`]** 위 "모듈 스코핑" 절의
|
||||
정정과 맞춰 — "자동으로"는 아니고 module-level state를 참조하는 코드는
|
||||
손을 봐야 함, 그 손질까지 하고 나면 이 가드 자체는 재설계 불필요라는
|
||||
뜻). **이 결론이 Dispatch의 handler 레지스트리에도
|
||||
그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** —
|
||||
`base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절.
|
||||
|
|
|
|||
|
|
@ -521,7 +521,8 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef)
|
||||
function ProcessedPreRefHandler.process(inst, i, v)
|
||||
-- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가
|
||||
-- 끝에서 recompute를 돌리므로(`base/dispatch-core-plan.md`의 해제 순서 계약)
|
||||
-- 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
|
||||
-- (`base/dispatch-core-plan.md`의 해제 순서 계약)
|
||||
Dispatch.setOffsetSource(inst, i, None)
|
||||
Dispatch.setLength(inst, i, 0)
|
||||
return function() end -- no-op retract, 이 자리는 fire가 끝나
|
||||
|
|
@ -606,9 +607,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함.
|
||||
전용 `Handler`를 하나 등록: `{ priority = HANDLER_PRIORITY_FALLBACK,
|
||||
isHandlable = function(inst,k,v) return isPreRef(v) end, process =
|
||||
function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수
|
||||
있음") end }` — `k` 타입은 안 가림(숫자든 문자열이든 `isPreRef(v)`만
|
||||
보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 전담하는 Handler"
|
||||
function(inst,k,v) error(`PreRef binding should be array index item,
|
||||
but got {typeof(k)}`) end }`(**[2026-08-18, `/code-review high`로
|
||||
누락 발견 — `PostRef`의 "동적 경로 가드 Handler도 거울상으로 하나 더" 절/
|
||||
`effect-plan.md`의 "동적 경로 가드" 절과 짝을 맞춤]** 에러 메시지에
|
||||
실제 `k` 타입을 실을 것) — `k` 타입은 안 가림(숫자든 문자열이든
|
||||
`isPreRef(v)`만 보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만
|
||||
전담하는 Handler"
|
||||
패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는
|
||||
`HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라
|
||||
`Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서
|
||||
|
|
|
|||
|
|
@ -359,8 +359,9 @@ function SlotHandler.process(inst, k, slotValue, index)
|
|||
-- 예전엔 destroySlotTree를 불러 그 결정과 정면으로 모순됐음.
|
||||
unmountSlotTree(slotValue)
|
||||
-- 이 위치가 더 이상 기여하지 않음을 owner에게 알림 — **순서 고정**
|
||||
-- (setLength가 끝에서 recompute를 돌리므로 offsetSource를 먼저 비워야
|
||||
-- 죽는 중인 Source에 헛된 :Set()이 안 감, 아래 ⚠️ 절 참고)
|
||||
-- (setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
|
||||
-- offsetSource를 먼저 비워야 죽는 중인 Source에 헛된 :Set()이 안 감,
|
||||
-- 아래 ⚠️ 절 참고)
|
||||
Dispatch.setOffsetSource(inst, k, None)
|
||||
Dispatch.setLength(inst, k, 0)
|
||||
unbindLifetime(slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝
|
||||
|
|
@ -847,9 +848,10 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it
|
|||
되는 식):
|
||||
- **버림** — 첫 번째 값으로 `nil`(또는 `None`, 동일 취급)을 반환.
|
||||
"지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면
|
||||
**[정정, 2026-08-13 3차 감사] 파괴가 아니라 언마운트됨**(단순
|
||||
`Visible = false`도 아님 — 위 "구현" 절 `rawUnmount` 참고, 이
|
||||
문단 작성 당시엔 아직 destroy 모델이었음). 편의상
|
||||
**[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전
|
||||
— "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible =
|
||||
false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시
|
||||
반환해야 대신 언마운트+재사용됨). 편의상
|
||||
`nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라
|
||||
`:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw
|
||||
`nil`/`None` 금지와 안 부딪힘).
|
||||
|
|
@ -974,12 +976,26 @@ end
|
|||
|
||||
**해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면
|
||||
그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil`
|
||||
반환으로 **[정정, 2026-08-13 3차 감사] 언마운트**되게 함(위 캐비엇대로
|
||||
파괴 아님) — Visible 토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만
|
||||
통과하는 필터면 20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로
|
||||
존재하지 않음(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한
|
||||
GC되기 전까지 `nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수
|
||||
있음 — 위 캐비엇 참고).
|
||||
반환으로 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로
|
||||
재역전 — 아래 참고] 언마운트**되게 함(위 캐비엇대로 파괴 아님) — Visible
|
||||
토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 통과하는 필터면
|
||||
20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 존재하지 않음
|
||||
(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 GC되기 전까지
|
||||
`nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 있음 — 위 캐비엇
|
||||
참고).
|
||||
|
||||
**⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale
|
||||
서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로
|
||||
쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil`
|
||||
리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소,
|
||||
`question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로
|
||||
`updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고,
|
||||
"Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면
|
||||
`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이
|
||||
절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제
|
||||
자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은
|
||||
`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위
|
||||
"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것.
|
||||
|
||||
**"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** —
|
||||
item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그
|
||||
|
|
@ -1457,11 +1473,32 @@ nil/None 금지)는 그대로.
|
|||
|
||||
### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용
|
||||
|
||||
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는
|
||||
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할
|
||||
수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`의
|
||||
"배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그
|
||||
해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래
|
||||
코드의 `blocker` 관련 줄).
|
||||
|
||||
`base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는
|
||||
owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나
|
||||
weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와
|
||||
중첩 마운트가 완전히 같은 함수 호출**이 됩니다.
|
||||
|
||||
**[재정정, 2026-08-18 구현 전 QA 2라운드 후속] 호출 순서가 뒤집혀 있었음
|
||||
— `setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.**
|
||||
`base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출
|
||||
순서는 `setOffsetSource` → `setLength`"** 일반 규칙(해제 시점 계약에서
|
||||
나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속
|
||||
어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게
|
||||
되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각
|
||||
요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset
|
||||
전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length`는 `activateList`가
|
||||
자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그
|
||||
전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는
|
||||
**`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야
|
||||
확정된 값으로 등록) → 물리 마운트**:
|
||||
|
||||
```lua
|
||||
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
|
||||
local function attachSlot(slot, physicalTarget, ownerKey, position)
|
||||
|
|
@ -1475,24 +1512,38 @@ local function attachSlot(slot, physicalTarget, ownerKey, position)
|
|||
slot._mounted = true
|
||||
slot._mountedInst = physicalTarget
|
||||
|
||||
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
|
||||
local offsetSource = Source(0)
|
||||
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
|
||||
Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산
|
||||
slot.Offset = offsetSource
|
||||
|
||||
if slot._listed then
|
||||
activateList(slot, physicalTarget) -- 기존 :List lazy activation, 안 바뀜
|
||||
activateList(slot, physicalTarget) -- 실체화 — 여기서 slot.Length가 진짜 값으로 확정됨
|
||||
end
|
||||
|
||||
-- attach 전에 이미 들어와있던 요소들 flush(이미 채워둔 Slot을 나중에
|
||||
-- 마운트하는 흔한 패턴이 원래도 전제하고 있던 것 — 새 개념 아님)
|
||||
Dispatch.setLength(ownerKey, position, slot.Length) -- 실체화 뒤 — 확정된 값으로 등록, 재전파 낭비 없음
|
||||
|
||||
-- [2026-08-18 신설, RC-1 해결] attach 전에 이미 들어와있던 요소들 flush —
|
||||
-- 이 루프도 Dispatch.drive의 최상위 배열 순회와 똑같이 "slot._elements의
|
||||
-- 개수(N)가 이미 정해진 채 position을 하나씩 등록"하는 배치라 같은
|
||||
-- 크래시 위험이 있음. 이 Slot 자신의 owner 키로 별도 Blocker를 새로
|
||||
-- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의
|
||||
-- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용.
|
||||
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 자신
|
||||
else
|
||||
-- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도
|
||||
-- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1.
|
||||
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
|
||||
end
|
||||
```
|
||||
|
||||
|
|
@ -1513,6 +1564,15 @@ end
|
|||
-- attachSlot될 때 위 flush 루프가 처리
|
||||
```
|
||||
|
||||
**이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인,
|
||||
2026-08-18)** — *"그건 이미 마운트가 된 이후라서 별 상관 없음... 새로운
|
||||
개체가 뒤에 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷
|
||||
요소를 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안
|
||||
받고 수행 가능함"* — 이 시점엔 `self`의 Blocker가 이미 flush 배치를
|
||||
끝내고 `OffWithoutEmit()`으로 꺼져 있고, 새로 등록되는 position보다
|
||||
앞선 모든 position은 이미 안정적으로 채워져 있어 `nil` 자리가 생길
|
||||
여지 자체가 없다.
|
||||
|
||||
`recompute`가 owner가 Slot이면 그 `.Length`에도 합계를 반영하도록
|
||||
확장됐으므로(`base/dispatch-core-plan.md` 참고) — `Slot.Length`는 더
|
||||
이상 raw 개수가 아니라 **"요소별 기여도의 합"**(plain=1, nested
|
||||
|
|
@ -1983,9 +2043,12 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기
|
|||
(2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할
|
||||
때는 이 순서를 반드시 지켜야 함**:
|
||||
|
||||
- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`는
|
||||
`sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함
|
||||
(`base/dispatch-core-plan.md` "Length/Offset" 절).
|
||||
- `Dispatch.setLength`는 끝에서 `gatedRecompute`를 경유해(배치 게이팅
|
||||
중이 아니면) `recompute`를 돌리고, `recompute`는 `sourceList`를
|
||||
순회하며 각 자리의 `offset:Set(sum)`을 호출함(`base/dispatch-core-plan.md`
|
||||
"Length/Offset" 절 — 해제는 배치 도중이 아니라 steady state에서 흔히
|
||||
일어나므로 이 경로에서는 `gatedRecompute`가 거의 항상 즉시 `recompute`로
|
||||
이어짐).
|
||||
- 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는
|
||||
시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset
|
||||
`Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
`type-recursive-issue-try-callback/` 등).
|
||||
- `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을
|
||||
담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도
|
||||
여기 둠(`pre-implementation-qa-round1.md`, 2라운드 예정 — 라운드마다 새
|
||||
파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
|
||||
여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`
|
||||
둘 다 **완료** — 라운드마다 새 파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
|
||||
**[2026-08-18 기준] 폴더 자체가 아직 없음**.
|
||||
`.claude/archive/`는 원래 같은 취급이었으나
|
||||
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2
|
|||
어긋난다**(문서 스스로 이 상태를 UB로 규정). M3(Slot) 착수 전에 결론이
|
||||
필요한 항목.
|
||||
|
||||
### D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 ⚠️ 재확인 필요
|
||||
### D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 ✅ 해소(같은 세션 내 재역전)
|
||||
|
||||
- **판정**: 아니오(잠정) — 사용자는 **quad-base 로드 시 등록이 맞다고 했었다**고
|
||||
기억하며, 문서의 현재 확정과 반대. 다만 사용자도 "더 확인이 필요"라고 함.
|
||||
|
|
@ -359,6 +359,18 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2
|
|||
`archive/tag-attribute-load-time-registration-reversed.md`를 되살릴지
|
||||
아니면 새로 쓸지, (c) `InitNamespace` 거부 원칙과 어떻게 양립시킬지를
|
||||
같이 적어주시면 좋겠음.
|
||||
- **해소(같은 세션 내, 2026-08-18) — (a)/(b)/(c) 전부 답변됨**:
|
||||
(a) **최종은 로드 시 등록** — "등록 주체는 다시 quad-base 자신이다
|
||||
(모듈이 자기 레지스트리를 구성하는 시점)"으로 재역전 확정. (b) 새로
|
||||
안 쓰고 **기존 `archive/tag-attribute-load-time-registration-reversed.md`에
|
||||
재역전 배너만 추가**(그 문서 자체가 "이번에 재역전됐다"는 걸 스스로
|
||||
알림 — 새 archive 문서 생성 없음). (c) `InitNamespace` 거부 원칙과의
|
||||
양립: 그 원칙이 금지한 건 "사용자가 수동으로 init을 호출하게 만드는
|
||||
것"과 "모듈 로드 시 *남의* 상태를 건드리는 것" 둘인데, base가 **자기
|
||||
모듈 안의 자기 레지스트리**를 자기가 채우는 건 그 어느 쪽도 아니므로
|
||||
충돌 없음. 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "base가
|
||||
소유하는 핸들러와 주입되는 엔진 op" 절의 "[재역전, 2026-08-18 구현 전
|
||||
QA — 사용자 확정]" 배너가 소스.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
340
.claude/qa-request/pre-implementation-qa-round2.md
Normal file
340
.claude/qa-request/pre-implementation-qa-round2.md
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
# 구현 전 QA **2라운드** — 의사코드 손 트레이싱
|
||||
|
||||
**상태**: **완료 — 핵심 발견(`RC-1`)까지 같은 날 후속 세션에서 해결·
|
||||
`base/` 반영 완료.** 1라운드는 "확정된 주장이 맞는가"를 물었을 뿐 의사코드를
|
||||
실제로 실행해보진 않았음(`pre-implementation-qa-round1.md` 맨 아래 "진행
|
||||
로그" 절). 2라운드는 그 갭을 메우는 작업 — `base/slot-plan.md`의 `:List`
|
||||
`reconcile`과 `base/dispatch-core-plan.md`의 `recompute`를 구체 시나리오로
|
||||
직접 손으로 실행해보고, 부수로 `reference/` 인용 정확성과 `ROADMAP.md`
|
||||
마일스톤 정합성도 훑었다.
|
||||
|
||||
**이 문서의 용도**: 1라운드와 달리 "예/아니오 판정" 문항이 아니라 트레이싱
|
||||
결과 자체가 산출물 — 버그를 찾으면 그 자리에서 기록하고, 방향이 갈리는
|
||||
것만 사용자에게 물었다. 새로 발견된 결함은 `RC-1` 하나뿐이고, 나머지는
|
||||
"트레이싱했지만 문제 없음 확인"으로 아래 각 절에 남긴다(재작업 방지용
|
||||
기록 — `conventions.md`의 "작업이 끝나면(또는 방향이 바뀌면) 항상 자기
|
||||
문서화" 원칙).
|
||||
|
||||
---
|
||||
|
||||
## RC-1 — `recompute`의 트리거 모델 자체가 재검토 대상 ✅ 해결(2026-08-18 후속 세션)
|
||||
|
||||
**판정**: 트레이싱으로 실제 크래시 경로를 확인 — 사용자도 진단은 맞다고
|
||||
동의했고, 같은 날 후속 대화에서 **Blocker를 재사용하는 배치 게이팅
|
||||
설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제
|
||||
반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는
|
||||
Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절,
|
||||
`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례"
|
||||
절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다.
|
||||
|
||||
### 발견한 크래시 경로
|
||||
|
||||
`base/dispatch-core-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때
|
||||
순서 보장" 절의 `recompute` 의사코드:
|
||||
|
||||
```lua
|
||||
local function recompute(ownerKey, bk)
|
||||
local sum = 0
|
||||
for i = 1, bk.N do
|
||||
local offset = bk.sourceList[i]
|
||||
if offset ~= nil and offset ~= None and offset:Get() ~= sum then
|
||||
offset:Set(sum)
|
||||
end
|
||||
local v = bk.lengthList[i]
|
||||
sum += (if isState(v) then v:Get() else v) -- ← v가 nil이면 여기서 산술 에러
|
||||
end
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
**구체 시나리오 — `Frame{A, B}`(정적 자식 2개, 반응형 없음, `N=2`).**
|
||||
|
||||
1. `Dispatch.drive(inst, flattened)`가 배열 파트를 순서대로 순회 —
|
||||
`bk.N`은 "그 `inst`의 array part 크기" 이므로 순회를 **시작하는 시점에
|
||||
이미 `2`로 정해져 있음**(같은 절의 "저장 위치" 문단).
|
||||
2. position 1(`A`)을 담당하는 말단 Handler가 `Dispatch.setLength(inst,1,1)`을
|
||||
부름. `setLength`의 의사코드는 **끝에서 무조건 `recompute(inst,bk)`를
|
||||
호출**(같은 절 "`setLength` 구현" 문단 — "등록 즉시 1회(Observer 자체의
|
||||
'등록 즉시 1회 실행'과 겹쳐도 무해)").
|
||||
3. 이 시점의 `recompute` 루프가 `i=1..bk.N(=2)`을 돎 — `i=1`은
|
||||
`bk.lengthList[1]=1`이라 정상. `i=2`는 **position 2(`B`)가 아직 처리되기
|
||||
전이라 `bk.lengthList[2]`가 `nil`** — `sum += (if isState(v) then
|
||||
v:Get() else v)`에서 `sum += nil`이 되어 산술 에러로 크래시.
|
||||
|
||||
`sourceList`(offset) 쪽은 같은 절에 이미 nil 가드가 있음(*"[방어,
|
||||
2026-08-13 여섯 번째 세션] `nil`도 같이 배제"*) — `lengthList` 쪽엔 그
|
||||
대칭 가드가 없다. 정적 자식 2개짜리 `Frame`처럼 **가장 흔한 경우**에서
|
||||
바로 재현되는 경로라, 별도 `Slot`/반응형 값 없이도 걸림.
|
||||
|
||||
### 왜 지금까지 안 잡혔나
|
||||
|
||||
기존 `luau-test/` 스파이크 중 이 함수를 다루는 건
|
||||
`done/20-slot-splice-index-arithmetic.luau` 하나뿐인데, 이건 `Splice`의
|
||||
순수 배열 인덱스 산술(제거/삽입 시 뒤 요소가 몇 칸 밀리는지)만 검증하고
|
||||
**`Dispatch.drive`가 여러 position을 순차 처리하며 `bk.lengthList`를 점진적으로
|
||||
채우는 과정 자체는 다루지 않음** — 이 경로를 실행해본 스파이크가 없었다.
|
||||
같은 함수의 다른 버그(offset이 자기 자신을 포함해 누적되던 off-by-one)는
|
||||
2026-08-11 세션에 이미 한 번 발견·수정됐지만(같은 문서 "recompute — 매번
|
||||
전체 순회" 문단), 이번 것은 그와 별개의 문제.
|
||||
|
||||
### 사용자 답변 원문 — 진단은 맞다고 확인, 그러나 더 큰 재설계 필요
|
||||
|
||||
> setLength/setOffset 자체는 리레이아웃을 트리거하진 않아야한다고 생각함.
|
||||
> 명시 리트리거를 해야하는게, 이러면 첫 실행에서 계속 recompute 비용이
|
||||
> 쌓임. 옵져버 생성 시 클로저 위쪽에 init = true 두고 처리할 필요가
|
||||
> 있는듯 하고, 각각의 process 가 setLength 를 나중에 수행했을 때는
|
||||
> recompute 를 어떻게 할지 생각해봐야할듯. 등록을 배치로 미룸은 맞는데,
|
||||
> State<None|Slot> 이 오는건 어쩌냐를 잘 모르겠음. 각각 처음에 바운딩
|
||||
> 할 땐 그럼 offset/length 어떻게 계산할지는 이것도 아직 모르겠음..
|
||||
> 생각을 더 해
|
||||
|
||||
**애초에 열려 있던 세 갈래**(사용자가 처음엔 하나로 안 좁힘) — (1)
|
||||
`setLength`/`setOffsetSource` 자체는 `recompute`를 트리거하지 않아야
|
||||
한다(명시적 리트리거 필요), (2) 등록을 배치로 미루는 방향은 맞지만
|
||||
`State<Slot|None>`처럼 값이 나중에 도착하는 경우를 어떻게 커버할지 불명,
|
||||
(3) 최초 바인딩 시점의 offset/length 계산 방식 자체가 안 잡힘 — 아래가
|
||||
같은 날 후속 대화에서 이 세 갈래를 하나로 합친 결과다.
|
||||
|
||||
### 해결 — Blocker 게이팅 (2026-08-18, 같은 날 후속 세션)
|
||||
|
||||
**사용자가 직접 제시한 설계 원문**:
|
||||
|
||||
> 각각의 length 들을 그냥 받아서 바인딩 하는게 아니야. 각 Frame 에 대한
|
||||
> Blocker 를 릴레이션으로 가지고 있고 이건 드라이빙 함수가 실행될 때
|
||||
> 생성돼. Offset/Length 소스가 설정 될 때, 특히 Length 에 있어서는 이
|
||||
> Frame->Blocker 로 있는걸 얻어와서 한번 적용하고 넣어둬. 그리고 Observer
|
||||
> callback 에서도 Blocker 가 IsOn 상태면 무시해줘. 드라이브 함수가
|
||||
> 실행되어 Blocker 가 생성될 때 기본으로 On 을 해줘. 이러면 length 가
|
||||
> 변경되며 계속 recompute 되지 않아. 그런 다음 OffWithoutEmit 을 해.
|
||||
> 맨 마지막으로 recompute 를 한번 하면 되는식이야.
|
||||
>
|
||||
> 다만, 이러면 초기에 레이아웃이 이상해져. 주의할 점은 setOffsetSource
|
||||
> 를 하게 되면 이건 자기 자신 위쪽으로 있는 요소들의 length 를 합해서
|
||||
> 설정해줘야할거야. 이건 첫 for 루프에서도 작동한다고 봄. 즉, 사실 첫
|
||||
> 루프 상 recompute 자체는 필요 없어. 그리고 setOffsetSource 는 여전히
|
||||
> slot 의 실체화로 List 가 수행되기 전에 설정되어야하고, length 가 확정
|
||||
> 된 다음에 다음 요소로 넘어가야해.
|
||||
|
||||
**요지 두 갈래로 나뉜다**:
|
||||
1. **`recompute`를 배치가 끝날 때까지 아예 안 돈다** — owner(inst 또는
|
||||
Slot)마다 `Relate`로 들고 있는 전용 `Blocker`를 배치 시작 시 `On`,
|
||||
`setLength`의 Observer 콜백(등록 즉시 1회 실행 포함)이
|
||||
`blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가 끝나면
|
||||
`OffWithoutEmit()` + 명시적 `recompute` 딱 1회.
|
||||
2. **`setOffsetSource`는 그 즉시 앞선 형제들의 길이 합을 직접 계산해
|
||||
`:Set`한다** — recompute를 미루는 것만으로는 "초기 레이아웃이
|
||||
이상해지는" 문제(배치 도중 `:List`가 실체화되며 옛/기본값 offset을
|
||||
읽어버림)가 남기 때문에, offset 자체는 즉시 계산으로 옮겨 recompute를
|
||||
기다리지 않게 함.
|
||||
|
||||
**뒤이은 확인 질문 3개와 답변**(`AskUserQuestion`, 같은 세션):
|
||||
|
||||
- **중첩된 Slot이 `attachSlot`을 재귀할 때도 각자 별도 Blocker가
|
||||
필요한가?** → *"예 — 중첩마다 별도 Blocker (권장)"* —
|
||||
`base/blocker-plan.md`의 재진입(네스팅) 미지원 규칙 그대로 적용.
|
||||
- **런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 경우도
|
||||
같은 위험이 있는가?** → *"그건 이미 마운트가 된 이후라서 별 상관
|
||||
없음. 가장 큰 문제는 마운트 중간에 후행 nil 이 있는데 recompute 가
|
||||
난다는게 문제... 정확한 내 의견은 이래: setLength 는 recompute 를
|
||||
직접 수행하진 않고, Observer 에서 recompute 를 수행해. 맨 처음 emit
|
||||
에서도 blocker 가 on 이면 무시하는식. 그럼에도 새로운 개체가 뒤에
|
||||
붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 요소를
|
||||
밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 받고
|
||||
수행 가능함"* — 크래시는 오직 "N이 미리 정해진 채 배치로 등록"되는
|
||||
두 자리(`Dispatch.drive`, `attachSlot`의 flush)에서만 나고, 런타임
|
||||
단건 append는 이미 안정된 앞선 position만 참조하므로 무관함이 확정.
|
||||
`setLength`가 `recompute`를 직접 안 부르고 Observer 콜백(첫 실행
|
||||
포함)만을 경유한다는 것도 이 답변에서 확정됨.
|
||||
- **`IsOn`/`HasBlocked`를 기존 `IsBlocked`/`HasBlockedEmit`과 어떻게
|
||||
관계지을까?** → *"IsBlocked가 있다면 그냥 두어도 될듯 함.
|
||||
HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 기존 필드는 그대로 두고,
|
||||
`IsOn()`은 `IsBlocked`를 읽는 얇은 조회 메소드로만 추가. 처음 요청했던
|
||||
`HasBlocked`(Blocker 자신의 새 최상위 플래그)는 **신설하지 않음** —
|
||||
`OffWithoutEmit()`이 각 gated state의 기존 `HasBlockedEmit`을 그대로
|
||||
리셋해주는 것으로 충분하다고 판단.
|
||||
|
||||
### 후속 정정 — `attachSlot`의 호출 순서가 뒤집혀 있었음 (같은 세션, RC-1 반영 직후)
|
||||
|
||||
위 설계를 `attachSlot`에 실제로 반영하는 과정에서 사용자가 직접 짚은
|
||||
추가 결함: `attachSlot`의 기존 의사코드는 `setLength`를 먼저, `setOffsetSource`를
|
||||
나중에 불렀는데, 이건 `base/dispatch-core-plan.md`의 "`NilHandler`" 절이
|
||||
이미 확정해둔 **"호출 순서는 `setOffsetSource` → `setLength`"** 일반
|
||||
규칙과 어긋나 있었다(RC-1로 `setOffsetSource`가 즉시 계산을 하게 되면서
|
||||
이 불일치가 드러남 — 그 전엔 둘 다 `recompute`에 얹혀 있어서 순서가
|
||||
겉으로 안 드러났었다). 사용자 확정 원문:
|
||||
|
||||
> length 를 알게되는 시점은 각 요소가 생성된 이후인데, 그럼 setOffset
|
||||
> 이 먼저 안 되어있으면 offset 전파가 한번 더 일어나게됨. 따라서 위가
|
||||
> 맞음
|
||||
|
||||
즉 Slot의 진짜 `.Length`는 `activateList`가 자기 `:List`를 최초
|
||||
reconcile한 **뒤에야** 확정되므로, 순서는 **`setOffsetSource`(즉시 계산)
|
||||
→ (Slot이면) `activateList` 실체화 → `setLength`(그제서야 확정된 값으로
|
||||
등록) → 물리 마운트**여야 한다 — `setLength`를 실체화 전에 부르면 등록
|
||||
직후 값이 또 바뀌어 전파가 한 번 낭비된다. 평범한 Instance 요소도 같은
|
||||
순서(`setOffsetSource(None)` → `setLength(1)` → `Parent` 대입)를 따르며,
|
||||
이 경로는 기존 의사코드에 아예 안 보이던 것도 이번에 같이 채워짐.
|
||||
|
||||
**부수 확정 — 코루틴 yield 금지 불변식.** 사용자가 이 논의 말미에 지적:
|
||||
|
||||
> 모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 할듯. 안 그럼
|
||||
> 꼬이는 문제가 발생하지 않나 생각함
|
||||
|
||||
이 배치 게이팅 전체가 "position이 항상 순서대로, 끼어드는 코드 없이
|
||||
동기로 처리된다"는 전제 위에 있어서, `Dispatch.process`/`attachSlot`
|
||||
호출 체인 도중 코루틴 yield가 끼면 같은 owner의 Blocker를 다른 코드가
|
||||
그 사이에 건드릴 수 있다 — 명시적 불변식(UB 선언)으로 문서화하기로 확정.
|
||||
|
||||
**반영된 곳**:
|
||||
- `base/blocker-plan.md` — `IsOn()`/`OffWithoutEmit()` 신설, onunblock
|
||||
핸들이 `emit: boolean`을 받도록 변경, `state:Block()` 없이 직접 쓰는
|
||||
용례 신설, 재진입 규칙에 이 용례의 실제 사례 추가.
|
||||
- `base/dispatch-core-plan.md` — "Length/Offset" 절에 "배치 등록을
|
||||
안전하게 만드는 Blocker 게이팅" 절 신설(`setLength`/`setOffsetSource`
|
||||
재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈), 코루틴
|
||||
yield 금지 불변식 추가.
|
||||
- `base/slot-plan.md` — "재귀 메커니즘" 절의 `attachSlot`이 자기 flush
|
||||
루프를 자기 자신의 Blocker로 감싸도록, 그리고 `setOffsetSource`→`setLength`
|
||||
순서로 재작성(평범한 Instance 요소의 등록도 명시적으로 채움), 런타임
|
||||
단건 `Add` 경로는 게이팅 불필요함을 명시.
|
||||
|
||||
---
|
||||
|
||||
## SL — `base/slot-plan.md`의 `:List` reconcile 트레이싱 — 새 결함 없음, 기존 미해결 갭만 재확인
|
||||
|
||||
`base/slot-plan.md`의 "구현" 절(`activateList`/`reconcile` 의사코드)을 아래
|
||||
시나리오로 손으로 실행:
|
||||
|
||||
- **재정렬**(`[A,B,C]` → `[C,A,B]`, 값 동일) — `pos`/`keyIndex` 비교가
|
||||
`rawMove`를 정확한 절대 위치로 호출, 정상.
|
||||
- **키 제거**(`[A,B,C]` → `[A,C]`) — 소멸 루프가 `keyIndex`(직전 사이클 전체
|
||||
key 집합)를 순회해 `B`를 `rawRemove`, 나머지 `pos` 압축도 정상.
|
||||
- **필터 토글**(`updateFn`이 특정 key에 `nil` 반환) — `rawRemove`(파괴)
|
||||
경로를 타고, `pos`가 그 키만큼 증가 안 해 뒤 요소가 정상 압축됨.
|
||||
- **`PopOnly` 반환 후 재등장** — `mounted[key]=nil`이지만 `userdata[key]`가
|
||||
`{old=...}`를 강하게 붙잡아 GC를 막고, 다음 사이클에 `prev=nil`로
|
||||
받은 `updateFn`이 `ud.old`를 그대로 반환하면 `rawAdd`로 재마운트됨 — 문서
|
||||
서술대로 정상 동작.
|
||||
- **nested Slot 반환**(`isSlot(result)`) — `pos = candidateIndex - 1 +
|
||||
result.Length:Get()`으로 다음 형제의 위치가 정확히 밀림, "`:List`의
|
||||
`index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함" 절의 결론과
|
||||
일치.
|
||||
- **중복 key** — `seen[key]` 체크가 `updateFn` 호출 *전에* 있어 즉시
|
||||
`error`, 상태 오염 없음.
|
||||
|
||||
**PopOnly 홀드 중 키가 데이터에서 완전히 사라지는 경우**만 트레이싱으로도
|
||||
재현됨 — 소멸 루프가 `mounted[key]`(이미 `nil`)만 보고 `rawRemove`를
|
||||
건너뛰어, 그 요소가 파괴도 반환도 안 되고 참조만 끊겨 GC된다. 이건 **이미
|
||||
`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절과 `question.md`
|
||||
3번(`PopOnly`로 홀드 중이던 요소의 키가 사라지면 어떻게 처분하는가)이
|
||||
알고 있는 미해결 갭** — 이번 트레이싱은 그 갭이 실제로 재현됨을 손으로
|
||||
다시 확인했을 뿐, 새 발견이 아니다. 결론은 그대로 `question.md` 3번의
|
||||
(a)/(b)/(c) 선택지에 맡긴다.
|
||||
|
||||
---
|
||||
|
||||
## D — `base/dispatch-core-plan.md`의 하강 diff 재디스패치 트레이싱 — 문제 없음
|
||||
|
||||
"Dispatch 체인" 절의 `Dispatch.process`/`Dispatch.retractFrom`을 아래
|
||||
시나리오로 트레이싱:
|
||||
|
||||
- **최초 마운트**(`store.key = a`, `a: State<Tag>`) — `StoreBind`가 `index=1`을
|
||||
잡고 `a:Get()`을 들고 `Dispatch.process(inst,k,realv,2)`를 재귀, `chains`에
|
||||
`[1]=StoreBind`, `[2]=TagHandler`가 순서대로 쌓임. 정상.
|
||||
- **같은 핸들러로 값 교체**(`a`가 새 `Tag`를 내놓음) — `Dispatch.process`의
|
||||
(A) 분기가 인덱스 2 슬롯의 기존 `retractor`에 새 값을 넘기고 클로저를
|
||||
교체, 인덱스 1은 안 건드림. `chains` 구조·재귀 깊이 전부 문서 서술과
|
||||
일치.
|
||||
- **`State<State<Tag>>`** — 안쪽 재귀가 `index+1`이라는 별개 슬롯을 쓰므로
|
||||
StoreBind 싱글톤이 같은 `(inst,k)`에 두 번 매치돼도 슬롯이 안 겹침 —
|
||||
"`State<State<T>>`는 정상 지원 대상" 절의 결론과 일치.
|
||||
|
||||
새로 발견된 문제 없음.
|
||||
|
||||
---
|
||||
|
||||
## `reference/` 인용 정확성 — 표본 점검, 불일치 없음
|
||||
|
||||
`base/` 문서가 `reference/quad-v1-architecture.md`/`reference/
|
||||
comparison-fusion-vide.md`를 인용하는 자리 중 표본 3곳을 원문과 대조:
|
||||
|
||||
- `base/lifecycle-pattern.md`가 v1의 GC 방지 핫팩을 인용한 자리 — 원문
|
||||
(`PropertyChangedSignal("ClassName")`에 연결해 참조를 붙잡아두는 관용구)과
|
||||
일치.
|
||||
- `base/slot-plan.md`가 v1 `mount.lua`를 분석한 자리("부모/자식 부기까지
|
||||
했지만 다중 마운트 방지는 없었음") — 원문(`mount.lua`는 실제로 부모/자식
|
||||
부기(bookkeeping) + 라이프사이클 파괴까지 담당하는 무거운 모듈)과
|
||||
정합(다중 마운트 방지 부재는 문서 전체 맥락상 타당한 요약).
|
||||
- `base/tween-plan.md`가 Fusion의 Tween/Spring을 반면교사로 인용한 자리 —
|
||||
`reference/comparison-fusion-vide.md`의 해당 문구와 일치.
|
||||
|
||||
전수 대조는 아니고(`reference/`를 인용하는 자리 전체는 `base/*.md`에서
|
||||
`reference/`로 grep한 결과 10여 곳 — 정확한 개수는 grep 결과 자체가
|
||||
소스), 표본에서 불일치가 안 나와 전수 대조로 확장하지 않음.
|
||||
|
||||
## `ROADMAP.md` 마일스톤 정합성 — 1라운드 반영분 확인, 불일치 없음
|
||||
|
||||
1라운드에서 뒤집힌 것 중 `ROADMAP.md`가 언급하는 것들이 갱신됐는지 확인:
|
||||
`NilHandler`/`NoneHandler` 분리(**[정정, 2026-08-18 `/code-review high`]
|
||||
M2가 아니라 M7 — Modifier 마일스톤에 체크박스가 있음, M2엔 서술 문단
|
||||
안에서만 이름이 언급될 뿐 별도 체크리스트 항목이 없음**), `PopOnly` 반환
|
||||
경로(M6), `store:GetDynamic` 탑레벨/콜론 미정 표시(M3), `DI`→`D`
|
||||
리네임(전역) — 전부 반영 확인됨.
|
||||
`D-6`(`setLength`/`setOffsetSource` 호출 책임자를 "처음 매치한 Handler"로
|
||||
서술하던 옛 오류)의 흔적도 `ROADMAP.md`엔 없음(애초에 그 정도 구현
|
||||
디테일은 `base/`만 갖고 있고 `ROADMAP.md`는 체크박스 수준이라 옮겨붙을
|
||||
자리가 없었음).
|
||||
|
||||
---
|
||||
|
||||
## [중간 상태 기록, Blocker 해법 확정 이전] 반영
|
||||
|
||||
**⚠️ 이 절은 낡았다 — RC-1이 아직 "미해결"이던 시점(사용자가 "생각을 더
|
||||
해"라며 방향을 안 정했을 때)에 세워둔 임시 조치 목록이다.** 그 뒤 같은
|
||||
세션 후속 대화에서 Blocker 게이팅 설계가 확정되며 아래 내용 대부분이
|
||||
다시 뒤집히거나 대체됐다 — **지금 유효한 반영 목록은 위 "해결 — Blocker
|
||||
게이팅" 절의 "반영된 곳" 문단이 소스**, 여기서 다시 정리하지 않는다.
|
||||
`question.md`는 이 중간 단계에서 `RC-1`을 추가했다가 해소 단계에서
|
||||
다시 뺀 것이라 최종 diff엔 흔적이 안 남는다(2026-08-18 감사에서 확인) —
|
||||
다음 세션이 "question.md가 바뀌었어야 하는데 안 바뀌었다"고 오해하지
|
||||
않도록 남겨둠. 아래는 그 시점의 원문 그대로 보존(역사 기록):
|
||||
|
||||
- `question.md` 3번에 `RC-1`을 M2 착수 전 결론 필요 항목으로 추가(→ 이후
|
||||
해소되며 제거, `archive/question-resolved.md`로).
|
||||
- `.claude/todos.md` 00번(구현 전 QA 결과 요약)의 미해결 목록에 `RC-1`
|
||||
추가, 머리말을 "M2/M3 착수 전 필요"로 갱신(→ 이후 해소 반영으로 다시
|
||||
갱신).
|
||||
- `base/dispatch-core-plan.md`의 "Length/Offset" 절 `recompute`/`setLength`
|
||||
**의사코드 자체엔 손대지 않음**(nil 가드 같은 국소 수선도 넣지 않기로
|
||||
함) — 대신 그 절 바로 위에 미해결 배너를 추가(→ 이후 의사코드 자체가
|
||||
Blocker 게이팅으로 재작성됨).
|
||||
- `base/slot-plan.md`의 "재귀 메커니즘" 절 서두에 `RC-1` 포인터 추가(→
|
||||
이후 `attachSlot` 의사코드 자체가 재작성됨).
|
||||
- `ROADMAP.md` M2/M6 체크박스 3곳에 `RC-1` 경고/각주 추가(→ 이후 해결
|
||||
표시로 갱신).
|
||||
- `.claude/README.md`/`project-context.md`의 `qa-request/` 서술을 round2
|
||||
진행 중 상태로 갱신(→ 이후 완료 상태로 갱신).
|
||||
|
||||
---
|
||||
|
||||
## 진행 로그
|
||||
|
||||
**2라운드(2026-08-18) — `:List` reconcile + `recompute` 손 트레이싱, `reference/`
|
||||
표본 대조, `ROADMAP.md` 정합성 확인, 그리고 같은 날 후속 세션에서 `RC-1`
|
||||
해결까지 완료.** 1라운드가 "아직 안 본 것"으로 남겨둔 항목 중:
|
||||
|
||||
- `slot-plan.md`의 `:List` 내부 — **트레이싱 완료**(위 "SL" 절).
|
||||
- `dispatch-core-plan.md`의 `recompute` — **트레이싱 완료, `RC-1` 발견 →
|
||||
같은 날 Blocker 게이팅 설계로 해결**(위 "해결 — Blocker 게이팅" 절).
|
||||
- `reference/` — **표본 점검 완료**(전수는 아님, 위 참고).
|
||||
- `research/` 13개(1라운드 기록 당시 11개였으나 같은 날 세션 중
|
||||
`fastscroll-plan.md`/`spring-plan.md`가 추가돼 지금은 13개 —
|
||||
정확한 개수는 `research/` 폴더 자체가 소스) — **이번 라운드에서도 제외**,
|
||||
확정 전 문서라 우선순위 낮음(`.claude/README.md`의 `research/` 표 참고).
|
||||
- 루트 `ROADMAP.md` 마일스톤 분할 — **확인 완료**(위 "ROADMAP.md 마일스톤
|
||||
정합성" 절).
|
||||
|
||||
**남은 일**: 없음 — `RC-1`까지 닫혀 2라운드 자체가 완료됐다. 3라운드가
|
||||
필요해지면(예: 이번에 새로 들어간 `attachSlot`/`recompute`/`Blocker`
|
||||
의사코드를 다시 손으로 트레이싱하는 검증) 새 파일
|
||||
`pre-implementation-qa-round3.md`를 만들 것.
|
||||
|
|
@ -177,8 +177,10 @@
|
|||
참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은
|
||||
`updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`의
|
||||
`old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를
|
||||
고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **M8 착수 전
|
||||
필요**, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절.
|
||||
고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정,
|
||||
2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전
|
||||
필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가
|
||||
기본" 절.
|
||||
- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** —
|
||||
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
|
||||
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
|
||||
|
|
|
|||
|
|
@ -5,17 +5,27 @@
|
|||
(`.claude/question.md`, `luau-test/STATUS.md` 등).
|
||||
|
||||
|
||||
00. **⭐⭐ [2026-08-18 신설, 같은 날 반영 완료] 구현 전 QA 결과는
|
||||
`base/`에 전부 반영됐다 — 남은 건 아래 "결론 전에 착수 금지" 항목뿐.**
|
||||
사용자가 `base/` 확정 문서 전체를 문항으로 재심사한 결과(원본 문답과
|
||||
사용자 답변 원문은 `.claude/qa-request/pre-implementation-qa-round1.md`가
|
||||
소스), 확정으로
|
||||
00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2라운드 전부
|
||||
`base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를
|
||||
문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은
|
||||
`.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로
|
||||
적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정
|
||||
반영됐다**(개수는 그 문서가 소스, 여기서 세지 않음). 그대로 구현하면
|
||||
반대로 돌던 두 건(`canBound` 게이트 방향, gcconn/gchold 강/약)도 닫혔다.
|
||||
2라운드는 `:List`의 `reconcile`/`recompute` 같은 확정 의사코드를 실제로
|
||||
손으로 실행해보는 작업(원본과 진행 로그는
|
||||
`.claude/qa-request/pre-implementation-qa-round2.md`가 소스) — `recompute`
|
||||
트레이싱에서 `Frame{A,B}`처럼 정적 자식 2개짜리도 첫 마운트에 크래시하는
|
||||
경로(`RC-1`)를 찾았고, 같은 날 후속 대화에서 사용자가 직접 제시한
|
||||
Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다
|
||||
(`archive/question-resolved.md`의 `RC-1` 절).
|
||||
|
||||
**M0/M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다** — 전부
|
||||
`question.md` 3번에 올라가 있고, 각 `base/` 문서에도 ⚠️로 표시돼 있다:
|
||||
**M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다**(M0/M2는 여전히
|
||||
막혀 있지 않음, 0번 항목 참고) — 대부분 `question.md` 3번에도 올라가
|
||||
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
|
||||
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측
|
||||
확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음,
|
||||
여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다:
|
||||
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
|
||||
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.**
|
||||
- **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) —
|
||||
|
|
@ -26,7 +36,10 @@
|
|||
`:Unsubscribe()` 절) — M3 착수 전 확인.
|
||||
- **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림.
|
||||
- **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) —
|
||||
지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. M8 착수 전 필요.
|
||||
지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정,
|
||||
2026-08-18 `/code-review high` — `ROADMAP.md`의 M6 PopOnly 체크박스와
|
||||
대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`)
|
||||
아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음.
|
||||
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
|
||||
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
|
||||
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
|
||||
|
|
|
|||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -184,7 +184,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
|
||||
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
|
||||
(2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset"
|
||||
절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소)
|
||||
절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소).
|
||||
**[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음
|
||||
채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로
|
||||
해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔
|
||||
`recompute`를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는
|
||||
`base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker
|
||||
게이팅" 절.
|
||||
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
|
||||
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
|
||||
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
|
||||
|
|
@ -416,6 +422,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`도
|
||||
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
|
||||
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
|
||||
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
|
||||
해결됨**, 위 M2 항목 참고.
|
||||
- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
|
||||
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
|
||||
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
|
||||
|
|
@ -528,6 +536,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
|
||||
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
|
||||
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
|
||||
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
|
||||
해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기
|
||||
Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).**
|
||||
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
|
||||
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
|
||||
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상
|
||||
|
|
|
|||
Loading…
Reference in a new issue