audit(dispatch,slot): c33ae04 전체 감사 — 실제 버그 4건 수정, Slot 언마운트 전환, 재디스패치 모델 재설계안
c33ae04(인덱스 기반 Dispatch 재설계) 전체를 직접 정독. 방향은 옳으나 새로 쓴 의사코드가 손 트레이싱을 안 거친 채 커밋됐음이 드러나 실제 버그 4건 발견·수정: 1. Dispatch.process가 chains:SetStrong을 h.process 뒤에 둬서 최초 마운트에서 하위 위임 retractor가 통째로 유실(재귀가 자기 테이블을 만들었다 바깥이 덮어씀 — Slot이면 서브트리 중복 마운트로 직결). SetStrong hoist + 재귀 중 hole 방지용 no-op 점유 마커 추가. 2. Attribute 그룹이 process에서 retractFrom을 선행 호출해 인덱스 1이 무조건 비워지는 바람에 점유 체크(=소유권 충돌 감지)가 전혀 작동 안 함. 철거를 반환 클로저로 이동. 3. SlotHandler가 claim 실패에도 파괴적 클로저를 반환 — nested는 엄격 claimOwner, top-level은 claimOwnerAt(inst,k)로 분리. rawRemove의 releaseOwner 누락, destroySlotTree의 GC 타이밍 의존 오류도 수정. 4. Ref retractor가 spurious 재발행에서도 relate를 지워 dedup 무력화. 사용자 설계 결정: - State<Slot> 교체 = 파괴가 아니라 언마운트(state<Frame>와 동일). 포탈이 별도 기능이 아니라 이 결정의 귀결이 됨. 해제는 setOffsetSource(None) → setLength(0) 순서 고정(반대면 죽는 중인 서브트리 Source에 헛된 Set이 날아감). dispose(value) 신설 — 트리가 아직 요구하면 파괴 거부하고 error. - 재디스패치를 "하강 diff"로 재설계 — 래핑 핸들러의 retractFrom 선행 호출을 폐기하고 Dispatch.process가 핸들러를 먼저 비교. 힌트가 None/래퍼로 오염돼 깜빡임 방지가 조용히 꺼지는 결함이 계기. 아직 research/에만 있고 base 미반영(Attribute 이름 소유권 하나가 남음) — base 4개 문서에 경고 배너. 전파 누락 정리: ROADMAP.md가 2026-08-08 이전 모델로 남아있던 것, base 내 "3종 vs 4종 계약" 모순 6개 문서, luau-test/04가 없어진 가드를 검증하던 것 (04는 인덱스 모델 + 버그 1 재현 음성 대조군으로 전면 재작성). 재발 방지: bind-system-plan.md "Handler 작성 체크리스트"(7항목), relate-plan.md "언제 Relate를 쓰고 언제 쓰면 안 되는가" 신설. 다음 세션 최우선: question.md 0-Z(Attribute 이름 소유권) — 사용자가 직접 스케치하며 심층 분석 예정. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c33ae041b0
commit
8c784288b0
26 changed files with 2155 additions and 395 deletions
File diff suppressed because one or more lines are too long
|
|
@ -227,7 +227,7 @@ existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확
|
|||
표현식 문법(공식 릴리스 노트: <https://luau.org/news/2021-10-31-luau-recap-october-2021/#if-then-else-expression>)
|
||||
— `cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이
|
||||
falsy(`nil`/`false`)여도 정확하게 동작함(`bind-system-plan.md`의
|
||||
`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **[강화, 2026-08-12
|
||||
`Dispatch.retractUnder`(현 `retractFrom`) 정정 사례가 실제 버그 예시). **[강화, 2026-08-12
|
||||
세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`만
|
||||
쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그
|
||||
경우만 예외적으로 허용했으나, 안전 여부와 무관하게 `if-then-else`가
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
# Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브
|
||||
|
||||
> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행
|
||||
> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나
|
||||
> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로
|
||||
> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델
|
||||
> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가
|
||||
> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z**
|
||||
> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로
|
||||
> 구현하면 옛 모델로 짜게 됨** — 반드시
|
||||
> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것.
|
||||
|
||||
**상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는
|
||||
전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서
|
||||
`retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/
|
||||
|
|
@ -188,9 +198,13 @@ end
|
|||
이미 그 이름의 인덱스 1을 점유 중이면, 이 직접 쓰기의
|
||||
`Dispatch.process(inst,key,value,1)`가 그 자리에서 곧바로 점유 error —
|
||||
그룹↔직접 쓰기 충돌도 같은 점유 체크 하나로 잡힘.
|
||||
- **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.retractFrom`+
|
||||
`Dispatch.process` 페어로 위임 — 전용 키 객체도, 소유권 레지스트리도
|
||||
필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1).
|
||||
- **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.process`만으로
|
||||
위임하고(철거는 반환 클로저가 전담) — 전용 키 객체도, 소유권 레지스트리도
|
||||
필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1). **`process`가
|
||||
위임 직전에 `retractFrom`을 부르면 안 된다는 게 이 절이 성립하는
|
||||
전제** — 그러면 점유 여부와 무관하게 인덱스 1이 비워져 아래 점유
|
||||
체크가 무력화됨(2026-08-13 감사에서 실제 그렇게 적혀 있던 걸 정정,
|
||||
"메커니즘" 절 참고).
|
||||
- **패키지 경계**: `AttributeKey`는 이미 quad-roblox 소속(Tag와 달리
|
||||
base/roblox로 안 쪼갬, 아래 "패키지 배치" 절) — base쪽
|
||||
`Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름.
|
||||
|
|
@ -222,9 +236,15 @@ Store 필드 여러 개를 각각 `[AttributeKey<<T>> "name"] = store.name`으
|
|||
```
|
||||
Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음
|
||||
Attribute.Merged(a, b, ...): Attribute -- Tag.Merged와 동일 이유(헤테로지니어스 합성)
|
||||
attr:NameMap(): {[string]: Source<any>} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것)
|
||||
Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일)
|
||||
```
|
||||
|
||||
**[2026-08-13 감사에서 추가] `:NameMap()`은 원래 "메커니즘" 절 의사코드에만
|
||||
등장하고 이 API 목록엔 빠져 있었음** — Handler가 이름 집합을 순회하려면
|
||||
반드시 필요한 공개 접근자라 여기 명시(`Tag:Names()`가 `tag-plan.md`에서
|
||||
같은 이유로 빠져 있던 것과 같은 누락).
|
||||
|
||||
`Attribute.Merged`가 내부적으로 하는 일은 각 Store에서 이름 붙은 `Source`
|
||||
슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드
|
||||
Store 기각과 안 부딪히나" 참고.
|
||||
|
|
@ -244,38 +264,63 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구
|
|||
직접 위임**(경위는 위 "이름 소유권" 절, 원문은 `archive/
|
||||
checkpoint-handler-pattern-reversed.md`):
|
||||
|
||||
**그룹의 `process`** — `(inst, index)`(array-part 위치, `Tag`의
|
||||
`relate:GetStrong(inst,k)`와 동일 키잉 — `k`는 배열 인덱스)에서 호출되고,
|
||||
반환하는 클로저가 "지금 관리 중인 이름 집합"을 직접 캡처 — **별도 `Relate`
|
||||
**그룹의 `process`** — 다른 모든 핸들러와 똑같은 4-인자 계약
|
||||
`process(inst, k, v, index)`를 따름(`k`는 이 그룹 값이 놓인 array-part
|
||||
위치, `index`는 그 `(inst,k)` 체인 안에서의 재귀 깊이 — **둘은 완전히
|
||||
다른 것이라 헷갈리지 말 것**. 2026-08-13 감사 전까지 이 자리 시그니처가
|
||||
`process(inst, index, v)` 3-인자로 적혀 있었고 배열 위치를 하필 `index`로
|
||||
부르고 있어서, 코퍼스에서 유일하게 계약과 안 맞는 핸들러였음). 반환하는
|
||||
클로저가 "이 호출이 등록한 이름 집합"을 직접 캡처 — **별도 `Relate`
|
||||
불필요**(2026-08-13 다섯 번째 세션, 클로저가 매 호출마다 자기 자신의
|
||||
이름 집합을 새로 만들어 캡처하므로 사이클을 가로질러 저장해둘 이유가
|
||||
없어짐):
|
||||
|
||||
**[정정, 2026-08-13 감사] `process`는 `retractFrom`을 부르지 않는다 —
|
||||
철거는 전적으로 반환 클로저 몫.** 최초 작성본은 `process` 안에서 이름마다
|
||||
`Dispatch.retractFrom(inst, key, 1, source)`를 먼저 부른 뒤 `process`를
|
||||
불렀는데, 이러면 **인덱스 1을 누가 점유했든 무조건 비워버리므로 뒤따르는
|
||||
`Dispatch.process`가 점유 error를 낼 수가 없음** — 위 "이름 소유권" 절이
|
||||
이 재설계의 핵심 근거로 내세운 "점유 체크가 소유권 충돌 감지를 그대로
|
||||
대신함"이 그룹↔그룹 사이에서 전혀 작동하지 않았음(그룹 B가 그룹 A의
|
||||
바인딩을 조용히 파괴하고 이김 — 이 절이 없앴다고 선언한 바로 그
|
||||
last-write-wins). 클로저가 자기가 등록한 이름 전부를 책임지고 걷어내면
|
||||
`process`는 순수하게 `Dispatch.process`만 부르면 되고, 점유 체크가 다시
|
||||
살아남:
|
||||
|
||||
```lua
|
||||
function AttributeGroupHandler.process(inst, index, v)
|
||||
function AttributeGroupHandler.process(inst, k, v, index)
|
||||
local names = {}
|
||||
for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장
|
||||
local key = AttributeKey(name) -- 공개 캐시 그대로 — 그룹 전용 키 불필요
|
||||
Dispatch.retractFrom(inst, key, 1, source) -- 그 이름 자리에 이미 뭔가 있으면 통째로 철거(신규는 no-op)
|
||||
Dispatch.process(inst, key, source, 1) -- 항상 인덱스 1부터 새로 위임 — StoreBind/AttributeKeyHandler는 정상 스캔으로 쌓임
|
||||
-- 공개 캐시 키 그대로(그룹 전용 키 불필요), 항상 인덱스 1부터 위임 —
|
||||
-- 이미 다른 그룹/직접 쓰기가 그 이름을 점유 중이면 여기서 즉시 점유 error
|
||||
Dispatch.process(inst, AttributeKey(name), source, 1)
|
||||
names[name] = true
|
||||
end
|
||||
return function(hintValue)
|
||||
-- hintValue: 다음에 이 자리를 대체할 값(다른 Attribute, nil 등) — Tag/Ref와 같은 힌트 패턴
|
||||
local newNames = if isAttribute(hintValue) then hintValue:NameMap() else {}
|
||||
return function()
|
||||
-- 이 그룹이 등록했던 이름 전부를 철거 — 생존/소멸 구분 없이 균일.
|
||||
-- hintValue를 안 봄: 생존 이름도 일단 철거하고 다음 process가 다시
|
||||
-- 등록하는 순서라(StoreBind가 retractFrom → process 순서를 보장),
|
||||
-- "다음 값에 이 이름이 있나"를 미리 알 필요가 없음.
|
||||
for name in pairs(names) do
|
||||
if newNames[name] == nil then -- 더 이상 이 그룹이 안 쓰는 이름만
|
||||
Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙)
|
||||
end
|
||||
Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- **`process`는 매번 살아있는 이름 전부를 `retractFrom`+`process`
|
||||
페어로 재위임** — 신규/생존 구분 없이 균일 처리(신규 이름은 아직 체인이
|
||||
없어 `retractFrom`이 그냥 no-op이라 이 페어링을 통일해도
|
||||
비용 없음). **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State
|
||||
- **생존 이름도 매 사이클 철거→재등록됨(의도된 트레이드오프)** — 비용은
|
||||
그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회
|
||||
실행"이 같은 값으로 `SetAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미
|
||||
"값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목)
|
||||
결이 같고, `SetAttribute`는 같은 값 재기록이 관측상 무해함. **`Tag`식
|
||||
`hintValue == v` 조기 반환을 여기에 넣으면 안 됨** — 클로저가 아무것도
|
||||
안 걷어낸 상태로 다음 `process`가 같은 이름에 다시 인덱스 1을 잡으려
|
||||
들어 자기 자신에게 점유 error를 냄.
|
||||
- **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가
|
||||
`names` 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진
|
||||
이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없어짐(이전 버전이
|
||||
`newNames`를 계산하던 로직 자체가 불필요).
|
||||
- **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State
|
||||
계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지",
|
||||
`store-semantics.md` "하드 경계" 절)과 어긋나고, `source`가
|
||||
`State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다
|
||||
|
|
@ -299,14 +344,19 @@ end
|
|||
그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을
|
||||
나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라
|
||||
모호하지 않음, 지금은 범위 밖(백로그).
|
||||
- **다만 사라진 이름의 *구독*은 끊음 — 값은 안 지워도 자원은 새면
|
||||
- **다만 *구독*은 반드시 끊음 — 값은 안 지워도 자원은 새면
|
||||
안 됨.** 위 "값은 안 지운다" 원칙과 별개로, 그룹이 더 이상 관리하지
|
||||
않는 이름의 `(inst,key)` 체인을 그대로 두면 그 키에 걸려있던
|
||||
`StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본
|
||||
`Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가
|
||||
됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가
|
||||
문제). 그래서 반환하는 클로저는 사라진 이름에 한해
|
||||
`Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`만 부름 —
|
||||
문제). 그래서 반환하는 클로저는 **자기가 등록했던 이름 전부**에 대해
|
||||
`Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`을 부름
|
||||
(**[정정, 2026-08-13 감사]** 원래는 "사라진 이름에 한해"였으나, 그
|
||||
선별을 하려면 `process` 쪽이 생존 이름을 `retractFrom`으로 강제
|
||||
회수해야 해서 점유 체크가 무력화됐음 — 위 "메커니즘" 절 참고. 전부
|
||||
걷어내고 새 `process`가 다시 등록하는 쪽이 점유 체크를 살리면서
|
||||
코드도 더 단순함) —
|
||||
**`Dispatch.process`는 절대 안 부르므로**(이 클로저 안에서 새 등록을
|
||||
트리거하는 건 체인 추적을 꼬는 UB, `bind-system-plan.md` 일반 규칙)
|
||||
그 이름 아래가 전부 자기 자신의 클로저만 타고 끝나 `SetAttribute`는
|
||||
|
|
@ -319,7 +369,10 @@ end
|
|||
**`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** —
|
||||
키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎.
|
||||
|
||||
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제
|
||||
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합을 순회하며 위임하고,
|
||||
클로저가 같은 집합을 걷어내는 것"뿐**(**[정정, 2026-08-13 감사]** 예전엔
|
||||
"이름 집합 diff"라고 적었으나, 위 재정정으로 diff 자체가 없어짐 —
|
||||
`process`는 새 집합을 전부 등록, 클로저는 옛 집합을 전부 철거) — 실제
|
||||
`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키
|
||||
경로가 그대로 담당. `TagHandler`가 `CollectionService` 호출을 직접 하는
|
||||
것과 달리, 여기서는 그 실행 자체를 위임한다는 점이 다름(Attribute만
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
# Bind 시스템 — pluggable key/value 핸들러 (base로 승격됨)
|
||||
|
||||
**상태**: base — 핵심 디스패치 모델(`process`/`retract`, 핸들러 4종 계약,
|
||||
> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행
|
||||
> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나
|
||||
> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로
|
||||
> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델
|
||||
> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가
|
||||
> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z**
|
||||
> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로
|
||||
> 구현하면 옛 모델로 짜게 됨** — 반드시
|
||||
> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것.
|
||||
|
||||
**상태**: base — 핵심 디스패치 모델(`process` + 그가 반환하는 retract
|
||||
클로저, 핸들러 3종 계약 — 2026-08-13 다섯 번째 세션에 별도 `retract`
|
||||
필드가 반환값으로 합쳐지기 전엔 `process`/`retract` 4종이었음,
|
||||
Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 엔진은
|
||||
`quad-base`가 인터페이스로 소유, `quad-roblox`는 실제 구현만)까지 전부
|
||||
2026-08-04 세션에서 확정되어 `research/`에서 승격됨(`base/architecture.md`의
|
||||
|
|
@ -227,8 +239,14 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
|
|||
이 클로저는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 새
|
||||
등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹
|
||||
로직이 클로저 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는
|
||||
순서로만 일어나야 함. `Dispatch.retractFrom`을 (다른 키에 대해)
|
||||
이 클로저 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`.
|
||||
순서로만 일어나야 함. **`Dispatch.retractFrom`은 "다른 키에 대해서만"
|
||||
허용** — `Attribute` 그룹이 자기가 위임했던 `AttributeKey(name)`들을
|
||||
걷어내는 게 정확히 이 경우. **같은 `(inst,k)`에 대해 이 클로저 안에서
|
||||
`retractFrom`을 부르는 것도 `process`와 똑같이 금지(UB)** — 지금
|
||||
돌고 있는 바깥 `retractFrom`의 루프가 `#list`를 이미 캡처한 채
|
||||
꼬리부터 내려오는 중이라, 그 도중에 같은 list를 다시 훑으면 같은
|
||||
retractor가 두 번 불리거나 건너뛰어짐(2026-08-13 감사에서 명시화 —
|
||||
원래는 "다른 키에 대해"라는 괄호로만 암시돼 있었음).
|
||||
- **자기 자신의 하위 위임까지 클로저 안에서 수동으로 다시 정리할
|
||||
필요 없음** — 위 "핸들러 계약" 절 참고, `Dispatch.retractFrom`의
|
||||
순회 구조 자체가 항상 깊은 인덱스부터 정리하고 나서 얕은 인덱스로
|
||||
|
|
@ -362,7 +380,15 @@ end
|
|||
뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와
|
||||
flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시
|
||||
파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에
|
||||
`Dispatch.process`를 호출하는 게 이 함수의 본체.
|
||||
`Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체.
|
||||
**진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입
|
||||
후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을
|
||||
처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와
|
||||
무관하게 항상 1부터"(아래 "Dispatch 체인" 절)라는 규칙의 가장 기본
|
||||
사례. 같은 키가 두 번 나올 수 없는 테이블 순회라 여기서 점유 충돌이
|
||||
날 일도 없음(단, 그룹 `Attribute`가 배열 파트에서 먼저 점유해둔
|
||||
이름을 해시 파트 직접 쓰기가 다시 건드리면 그건 정상적으로 점유
|
||||
error — `base/attribute-plan.md` "이름 소유권" 절).
|
||||
- **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은
|
||||
"리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라
|
||||
사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔
|
||||
|
|
@ -472,15 +498,29 @@ Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 **
|
|||
```lua
|
||||
-- Dispatch/init.luau
|
||||
local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = retractor}(strong)}}
|
||||
local NOOP = function() end
|
||||
|
||||
function Dispatch.process(inst, k, v, index)
|
||||
local list = chains:GetStrong(inst, k) or {}
|
||||
-- [순서 주의, 2026-08-13 감사] 아래 두 줄(list 확보 + chains 등록)은 반드시
|
||||
-- h.process 호출 *전에* 끝나야 함 — h.process가 내부에서 재귀
|
||||
-- Dispatch.process(inst,k,...,index+1)를 부르는 게 정상 경로이고(StoreBind/
|
||||
-- NoneHandler), 그때 chains에 이 list가 아직 안 들어가 있으면 재귀 호출이
|
||||
-- `or {}`로 자기만의 새 테이블을 만들어 저장해버린 뒤 바깥이 그걸 덮어써서
|
||||
-- 하위 위임 retractor가 통째로 유실됨(최초 마운트에서 항상 발생).
|
||||
local list = chains:GetStrong(inst, k)
|
||||
if not list then
|
||||
list = {}
|
||||
chains:SetStrong(inst, k, list)
|
||||
end
|
||||
if list[index] ~= nil then
|
||||
error("Dispatch: (inst,k)의 이 인덱스는 이미 점유돼 있음 — 먼저 retract할 것")
|
||||
end
|
||||
-- 점유 마커를 먼저 박는 이유: (1) h.process가 재귀하는 동안 list가 구멍
|
||||
-- 없는 시퀀스로 유지돼야 `#list`가 정의됨(hole 있는 테이블의 `#`는 Lua가
|
||||
-- 보장 안 함), (2) 핸들러가 같은 index로 재진입하는 버그도 이 가드에 걸림.
|
||||
list[index] = NOOP
|
||||
local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존 규칙대로 즉시 error
|
||||
list[index] = h.process(inst, k, v, index) -- 반환값 = retractor 클로저, 생략 불가
|
||||
chains:SetStrong(inst, k, list)
|
||||
end
|
||||
|
||||
function Dispatch.retractFrom(inst, k, index, v)
|
||||
|
|
@ -551,6 +591,37 @@ end
|
|||
엔진 `RemoveTag` 호출만 skip한다 — 상세는 `base/tag-plan.md` "메커니즘"
|
||||
절 참고. `hintValue`는 "계약상 항상 주어지지만 안 쓰는 핸들러가
|
||||
있어도 됨" 정도로 이해할 것.
|
||||
- **`hintValue`는 "직속 위임 1단계"에만 보장됨 — 깊은 인덱스는 항상 `nil`
|
||||
(2026-08-13 여섯 번째 세션 명시화).** 위 `retractFrom` 의사코드의
|
||||
`retractor(if i == index then v else nil)`이 그대로 계약임: 힌트를 받는
|
||||
건 **호출자가 지목한 `index` 자리 하나뿐**이고, 그보다 깊은(꼬리 쪽)
|
||||
인덱스들은 전부 `nil`을 받음. 결과:
|
||||
- **`State<Tag>`/`State<Ref>`/`State<Slot>`(한 겹)은 힌트가 확실히
|
||||
전달됨** — StoreBind가 인덱스 1, 실제 핸들러가 인덱스 2이고
|
||||
StoreBind는 `retractFrom(inst,k,index+1=2,realv)`를 부르므로
|
||||
`i == index`가 정확히 그 핸들러에 걸림. 즉 `Tag`의 `Contains` 힌트,
|
||||
`Ref`/`Slot`의 identity 비교 같은 깜빡임/재생성 방지가 **흔한
|
||||
경로에서는 유실 없이 동작**함.
|
||||
- **두 겹 이상(`State<State<Tag>>` 등)에서 바깥이 재발행하면 안쪽
|
||||
핸들러는 `nil` 힌트를 받음** — 구조적으로 불가피(바깥 단계는 안쪽
|
||||
State가 결국 어떤 값을 내놓을지 모름). 동작은 정상이지만 힌트 기반
|
||||
최적화만 꺼짐.
|
||||
- **[검토 후 기각] `chains`에 핸들러도 같이 저장해두고, 깊은 인덱스의
|
||||
핸들러가 새 값에 매치될 핸들러와 같으면 값을 한 겹 풀어 힌트로
|
||||
내려보내는 안**(사용자 제시). 두 가지 이유로 기각: (1) `process`와
|
||||
retractor 호출이 **1:1이 아님** — 전체 철거(`retractFrom(inst,k,1,nil)`)
|
||||
처럼 뒤따르는 `process`가 아예 없는 경로가 정상적으로 존재해서, 그
|
||||
경우엔 애초에 내려보낼 "새 값"이 없음(사용자 스스로 지적한 한계).
|
||||
(2) 더 근본적으로, 값을 한 겹 풀려면 teardown 도중에
|
||||
`innerState:Get()`을 **투기적으로** 호출해야 하는데 — State는
|
||||
pull-recompute 계약(`base/store-semantics.md`)이라 이 호출이 실제
|
||||
재계산을 유발할 수 있고, 곧이어 `process`가 다시 `:Get()`을 부르면
|
||||
같은 사이클에 이중 계산이 됨. 또 그렇게 얻은 값은 어디까지나 추정이라
|
||||
실제 `process` 시점의 값과 다를 수 있음("값 비교/캐싱 금지" 원칙과
|
||||
같은 결). **대신 채택할 방향은 값 층에서의 평탄화**(`State<State<T>>`
|
||||
→ `State<T>` 콤비네이터, `research/operator-sugar-plan.md` 백로그) —
|
||||
체인을 한 겹으로 유지하면 위 첫 번째 항목의 보장이 그대로 적용되므로
|
||||
Dispatch 층에 특수 배관을 넣을 이유가 없어짐.
|
||||
- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고
|
||||
B가 다시 A로 돌아오는 것, 또는 값 자체가 결국 자기 자신을 가리켜
|
||||
무한히 깊어지는 인덱스)는 재귀 호출이 안 끝나 바로 스택오버플로가
|
||||
|
|
@ -582,6 +653,73 @@ end
|
|||
무엇에 연결됐는가" 그래프도 이 `chains` 구조를 그대로 읽으면 됨 —
|
||||
quad-debug 착수 시점에 새로 설계할 필요 없음.
|
||||
|
||||
### Handler 작성 체크리스트 — 새 모델에서 실제로 반복된 실수들 (2026-08-13 여섯 번째 세션 신설)
|
||||
|
||||
**왜 이 절이 있는가**: 인덱스 기반 재설계 직후 작성된 의사코드
|
||||
(`Dispatch` 자신, `Ref`, `Tag`, `Slot`, `Attribute`)에서 **같은 세션
|
||||
안에 버그 4건**이 나왔고, 그중 셋이 서로 다른 문서에 있으면서도
|
||||
**같은 종류의 착각**에서 나왔음. 새 Handler를 짜거나 기존 걸 고칠 때
|
||||
이 목록을 먼저 훑을 것 — 전부 "그럴듯해 보이는데 틀린" 것들이라
|
||||
리뷰로 잡기 어렵다.
|
||||
|
||||
**1. 클로저는 early-return해도 체인에서 *소비*된다.**
|
||||
`Dispatch.retractFrom`은 저장된 retractor를 호출하고 **항상**
|
||||
`list[i] = nil`로 지움 — 그 클로저가 `hintValue == v`로 아무 일도 안 하고
|
||||
돌아왔더라도 마찬가지. 그러므로:
|
||||
- **매 `process` 호출은 "이 자리를 무르는 책임"을 온전히 새로 짊어진
|
||||
클로저를 반환해야 한다.** "이번엔 내가 실제로 한 일이 없으니 no-op을
|
||||
돌려주자"는 거의 항상 버그 — 다음 사이클에 진짜 교체가 올 때 정리할
|
||||
주체가 사라짐. (`SlotHandler`에서 실제로 이 함정에 빠졌었음.)
|
||||
- 반대로 "아무 일도 안 했으니 무를 것도 없다"가 **진짜로** 맞으려면,
|
||||
그 자리가 무를 자원을 애초에 아무도 안 갖고 있어야 함(일반
|
||||
PropertyHandler처럼).
|
||||
|
||||
**2. `process`가 재귀 위임 전에 `retractFrom`을 부르는 건 *같은 키에
|
||||
자기 아래*일 때만이다.**
|
||||
`StoreBind`가 `retractFrom(inst,k,index+1,realv)`를 부르는 건 자기가
|
||||
직전 사이클에 만든 하위 체인을 자기가 치우는 것 — 정당함. 반면 **다른
|
||||
키로 위임하면서 그 키의 인덱스 1을 미리 `retractFrom`으로 비우는 것은
|
||||
거의 항상 버그**: 그 자리를 누가 점유했든 지워버리므로
|
||||
`Dispatch.process`의 점유 체크(= 소유권 충돌 감지)가 통째로 무력화됨.
|
||||
`AttributeGroupHandler`가 정확히 이걸로 그룹↔그룹 충돌을 조용히
|
||||
넘기고 있었음. **다른 키의 정리는 그 키를 등록했던 클로저가 한다.**
|
||||
|
||||
**3. `hintValue`는 `nil`이 아니고, 타입도 보장 안 되고, 깊은 인덱스엔
|
||||
안 온다.** 셋 다 별개의 함정:
|
||||
- `nil`이라고 가정하고 `assert(v == nil)`을 쓰면 안 됨(이미 한 번 전면
|
||||
정정된 이력, `archive/retract-always-fires-reversed.md`).
|
||||
- 내용(메소드/필드)을 보려면 `isTag(hintValue)` 같은 가드부터 —
|
||||
그 자리 핸들러 *타입*이 바뀌는 경우 엉뚱한 값이 옴.
|
||||
- 깊이 2 이상에는 `nil`이 옴(위 "`hintValue`는 직속 위임 1단계에만
|
||||
보장됨" 항목) — 힌트가 있으면 최적화, 없으면 정직하게 전부 정리하는
|
||||
코드여야 함. 힌트를 **정확성**의 근거로 삼으면 안 됨.
|
||||
|
||||
**4. "이전 값"을 알고 싶으면 클로저 캡처, "여러 위치/사이클을 가로지르는
|
||||
누적 상태"만 `Relate`.** 이 경계를 헷갈리면 양방향으로 틀림:
|
||||
- 불필요한 `Relate`: `process`가 만든 걸 그 클로저가 정리하는 단발성
|
||||
handoff는 upvalue 캡처로 끝(옛 `kSlotMap`/`kTagMap`이 이걸로 삭제됨).
|
||||
- 부족한 `Relate`: `Tag`의 `tagNameMap`(여러 위치가 한 이름을 공유),
|
||||
`Ref`의 spurious 재바인딩 dedup처럼 **자기 클로저 수명 밖의 정보**는
|
||||
캡처로 대체 불가.
|
||||
- 그리고 `Relate`에 쓴 걸 클로저에서 지울 땐 **"내가 실제로 물러날
|
||||
때만"** 지울 것 — 조건 밖에서 무조건 지우면 dedup이 무력화됨
|
||||
(`RefLeafHandler`가 정확히 이 버그였음).
|
||||
|
||||
**5. `Dispatch`를 통해서만 진입한다.**
|
||||
`handler.process(...)`를 직접 부르면 점유 체크와 `chains` 기록이 통째로
|
||||
빠져 나중에 정리가 안 되거나 정합성이 깨짐(위 "Dispatch 체인" 절).
|
||||
마찬가지로 클로저 안에서는 `Dispatch.process` 금지, **같은 키**에 대한
|
||||
`Dispatch.retractFrom`도 금지(진행 중인 루프가 `#list`를 이미 캡처).
|
||||
|
||||
**6. 인덱스는 "같은 키 안의 재귀 깊이"다.**
|
||||
같은 키로 재귀하면 `index + 1`, **다른 키로 위임하면 그 키에서 다시
|
||||
`1`부터**, `Dispatch.drive`의 최초 진입도 `1`. 배열 파트의 위치(`k`)와
|
||||
이 `index`는 완전히 다른 것 — `AttributeGroupHandler`가 배열 위치를
|
||||
`index`라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음.
|
||||
|
||||
**7. 반환 생략 금지.** 정리할 게 없어도 `function() end`. `nil`을
|
||||
반환하면 `retractFrom`이 `attempt to call a nil value`로 크래시.
|
||||
|
||||
### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션)
|
||||
|
||||
**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문,
|
||||
|
|
@ -654,6 +792,19 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
|||
"`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면
|
||||
길이 합계와 실제 순서 계산이 어긋남).
|
||||
|
||||
**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`
|
||||
→ `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).**
|
||||
별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가
|
||||
반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저
|
||||
부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는
|
||||
서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이
|
||||
캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`의
|
||||
`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이
|
||||
틀려지는 문제는 아니지만(자기 length가 줄어도 자기 offset은 그대로라
|
||||
갱신될 일 자체가 없음) **invalid한 Source가 순회 대상에 남아있는 것
|
||||
자체가 위험**하므로 순서를 계약으로 고정. 상세·부수 방어 조치는
|
||||
`base/slot-plan.md`의 "구현상 바뀌어야 하는 것" 절 참고.
|
||||
|
||||
**둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB
|
||||
(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 —
|
||||
이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는
|
||||
|
|
@ -726,8 +877,12 @@ local function recompute(ownerKey, bk)
|
|||
for i = 1, bk.N do
|
||||
local offset = bk.sourceList[i]
|
||||
-- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라
|
||||
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함
|
||||
if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set
|
||||
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함.
|
||||
-- [방어, 2026-08-13 여섯 번째 세션] `nil`도 같이 배제 — 정상
|
||||
-- 상태에선 항상 None으로 채워지는 게 계약이지만(위 "None을 쓰는
|
||||
-- 이유"), 해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도
|
||||
-- 크래시 대신 skip이어야 함. 등록 쪽의 "반드시 None" 의무는 그대로.
|
||||
if offset ~= nil and offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set
|
||||
offset:Set(sum)
|
||||
end
|
||||
local v = bk.lengthList[i]
|
||||
|
|
@ -1193,8 +1348,13 @@ function RefLeafHandler.process(inst, k, v, index)
|
|||
if hintValue ~= v then
|
||||
v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
|
||||
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
|
||||
-- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야
|
||||
-- 함 — 밖에 두면 spurious 재발행(hintValue == v)에서도 기록이
|
||||
-- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고
|
||||
-- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이
|
||||
-- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음.
|
||||
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
|
||||
end
|
||||
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
|
@ -1516,9 +1676,11 @@ SyntheticEvent만 주는 것과 같은 모양).
|
|||
`Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도
|
||||
같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음.
|
||||
이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을
|
||||
per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는
|
||||
것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/
|
||||
`priority`/`process`/`retract`)만 제대로 구현하면 됨.
|
||||
`process`의 로컬 변수로 들고, 반환하는 retract 클로저가 그걸 upvalue로
|
||||
캡처해 `:Disconnect()`하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존
|
||||
계약(`isHandlable`/`priority`/`process`)만 제대로 구현하면 됨(**[2026-08-13
|
||||
다섯 번째 세션]** 예전엔 별도 `retract` 필드 + per-instance `Relate`
|
||||
저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐).
|
||||
|
||||
**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예
|
||||
없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는
|
||||
|
|
|
|||
|
|
@ -78,7 +78,9 @@ Svelte `Writable<T> extends Readable<T>`와 같은 모양), `store.key`는 Store
|
|||
|
||||
핸들러는 `State<T>` 하나만 받아도 Source 인스턴스가 서브타입 호환으로
|
||||
자동 통과된다(`Source<T> | State<T>` 유니온 불필요, `isHandlable`/
|
||||
`priority`/`process`/`retract` 4종 계약에 5번째 항목 추가 불필요). 런타임에
|
||||
`priority`/`process` 3종 계약에 항목 추가 불필요 — 2026-08-13 다섯 번째
|
||||
세션에 `retract`가 `process` 반환값으로 합쳐지기 전엔 4종이라 적혀
|
||||
있었음). 런타임에
|
||||
"이게 Source면 역방향 쓰기까지 걸고 싶다"처럼 구분하고 싶은 경우는
|
||||
`isSource`류 판별자로(`isObserver`와 동일한 패턴).
|
||||
|
||||
|
|
|
|||
|
|
@ -309,3 +309,12 @@ Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부
|
|||
철회한다"는 의미로 가장 정확 — `process`/`retract` 쌍으로 자연스럽게 대구를
|
||||
이룸.) 대부분의 문서에서 이 이름으로 갱신됨 — 잔여 "cleanup" 표기가 남은
|
||||
문서가 있을 수 있으며, 그 확인/정리는 진행 중.
|
||||
|
||||
**[2026-08-13 다섯 번째 세션] `retract`는 더 이상 Handler의 *필드*가
|
||||
아니라 `process`가 반환하는 클로저의 *역할 이름*이다.** 개념/이름
|
||||
자체는 그대로 유효하고(이 절의 결론은 안 바뀜), 다만 코드에서
|
||||
`handler.retract(...)`를 찾으면 안 됨 — `local retractor =
|
||||
handler.process(inst,k,v,index)` 형태로 받아서 `Dispatch`가 `chains`에
|
||||
보관했다가 부름(`base/bind-system-plan.md` "핸들러 계약"/"Dispatch 체인"
|
||||
절). 이 문서가 계속 쓰는 "retract 시점"/"retract가 불린다"는 표현은
|
||||
전부 그 클로저가 호출되는 시점을 가리킴.
|
||||
|
|
|
|||
|
|
@ -102,9 +102,11 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
|
|||
- ~~넘버 바인드/프로바이더 인터페이스의 정확한 함수 시그니처(base가 요구하는
|
||||
provider 인터페이스 계약)는 아직 미정~~ **[해소됨]** — 그 "provider
|
||||
인터페이스"가 곧 Handler 계약: `isHandlable(inst,key,value)`/
|
||||
`priority`/`process(inst,key,value)`/`retract(inst,key,value)` 4종,
|
||||
`retract`는 no-op이라도 필드 생략 불가까지 확정. `base/bind-system-plan.md`
|
||||
"핸들러 계약" 절.
|
||||
`priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13
|
||||
다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종
|
||||
계약이었으나, `process`가 자기 retract 클로저 `(hintValue) -> ()`를
|
||||
반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `function() end`
|
||||
반환 생략 불가까지 확정. `base/bind-system-plan.md` "핸들러 계약" 절.
|
||||
- ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히
|
||||
뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~
|
||||
**[해소됨]** — **`Handler`로 확정**, 위 항목이 가리키는 계약의 정식 이름.
|
||||
|
|
@ -116,8 +118,8 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
|
|||
늬앙스인데 Handler는 실제로 값을 공급하는 게 아니라 처리/반응하는
|
||||
쪽이라 의미가 안 맞고 React `Context.Provider`류 맥락(context) 패턴과도
|
||||
헷갈릴 수 있음, `Plug`는 "동적으로 꽂힌다"는 어감은 맞지만 "값을
|
||||
처리한다"는 의미가 빠져 있음 — `Handler`가 계약 4종
|
||||
(`isHandlable`/`priority`/`process`/`retract`) 전체를 가장 정확히
|
||||
처리한다"는 의미가 빠져 있음 — `Handler`가 계약 전체
|
||||
(`isHandlable`/`priority`/`process`, 위 항목 참고)를 가장 정확히
|
||||
담는다는 결론.
|
||||
- **[해소됨, 2026-08-12 열일곱 번째 세션]** provider(팩토리)가 아직 한
|
||||
번도 실행 안 된 상태에서 dispatch가 호출되면 어떻게 되는지
|
||||
|
|
|
|||
|
|
@ -43,14 +43,19 @@
|
|||
`GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에
|
||||
둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와
|
||||
다름.
|
||||
- **`process(inst,k,v)`**: `inst:GetPropertyChangedSignal(name):Connect(function()
|
||||
v(inst[name]) end)`. **`retract(inst,k,v)`**: 그 Connection을
|
||||
`:Disconnect()`. 일반 `Handlers/Event.luau`와 같은 결(Connection
|
||||
관리뿐, 새 메커니즘 없음).
|
||||
- **`process(inst,k,v,index)`**: `inst:GetPropertyChangedSignal(name):Connect(
|
||||
function() v(inst[name]) end)` 후 **그 Connection을 `:Disconnect()`하는
|
||||
클로저를 반환**. 일반 `Handlers/Event.luau`와 같은 결(Connection
|
||||
관리뿐, 새 메커니즘 없음). **[정정, 2026-08-13 다섯 번째 세션]** 원래는
|
||||
별도 `retract(inst,k,v)` 필드가 Disconnect를 담당한다고 적혀 있었으나,
|
||||
Handler 계약이 `process` 1-메소드로 합쳐지며 그 로직이 반환 클로저로
|
||||
이동 — `connection`은 `process`의 로컬 변수를 클로저가 upvalue로 그대로
|
||||
캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`bind-system-plan.md`
|
||||
"핸들러 내부 상태 저장" 절).
|
||||
- **`State<function>` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도
|
||||
store-bind 가능 — `false`로 disconnect" 메커니즘(`bind-system-plan.md`)이
|
||||
`OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`/`retract`만
|
||||
구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서
|
||||
`OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`(와 그
|
||||
반환 클로저)만 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서
|
||||
언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기
|
||||
불필요.
|
||||
- **`OnChange(name)`도 `AttributeKey`와 같은 이름별 weak 캐시 적용
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강
|
|||
`SetWeak`로 낮추고, 실제 GC 앵커는 `bindLifetime`/`unbindLifetime`
|
||||
하나로 통일**할 것(구체 사례는 `base/slot-plan.md` "Slot과 Store
|
||||
바인드의 관계" 절 참고 — `kSlotMap`(inst→slot)/`slotOwner`(slot→inst)가
|
||||
정확히 이 패턴이었음).
|
||||
정확히 이 패턴이었음. **[2026-08-13 다섯 번째 세션 이후]** 그 두 `Relate`는
|
||||
지금은 존재하지 않음 — `kSlotMap`은 Handler 계약이 클로저 반환으로 바뀌며
|
||||
불필요해져 삭제됐고 `slotOwner`는 `elementOwner`(전부 `SetWeak`)로
|
||||
일반화됐으므로, Slot 쪽엔 이 위험 패턴이 더 이상 남아있지 않음. 이 절은
|
||||
**일반 규칙**으로 계속 유효하고, Slot은 그 규칙이 실제로 적용됐던
|
||||
역사적 사례로만 인용).
|
||||
|
||||
## API (확정)
|
||||
|
||||
|
|
@ -86,6 +91,45 @@ relate:GetWeak(inst: any, key: any): any?
|
|||
간에 겹칠 걱정이 없음(모듈 하나가 감당할 key 개수는 보통 한두 개뿐이라
|
||||
`Relate()`를 여러 개 만드는 비용은 무시할 만함).
|
||||
|
||||
## 언제 `Relate`를 쓰고 언제 쓰면 안 되는가 — 체크리스트 (2026-08-13 여섯 번째 세션 신설)
|
||||
|
||||
Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌면서
|
||||
(`base/bind-system-plan.md` "핸들러 계약" 절) **`Relate`가 필요한 범위가
|
||||
크게 줄었음** — 이 전환기에 양방향으로 실수가 나왔어서 기준을 못박아 둠.
|
||||
|
||||
**쓰지 말 것 — 클로저 캡처로 충분한 경우**: 이 `process` 호출이 만든
|
||||
것을 **그 호출이 반환한 클로저가** 나중에 정리하는 단발성 handoff.
|
||||
`local observer = ...` / `local connection = ...`를 로컬로 두고 반환
|
||||
클로저가 upvalue로 캡처하면 끝 — `relate:SetStrong(inst,k,x)` 후
|
||||
`relate:GetStrong(inst,k)`로 되찾아오는 왕복이 통째로 불필요.
|
||||
2026-08-13 다섯 번째 세션에 `kSlotMap`(위치별 마지막 Slot),
|
||||
`kTagMap`(위치별 마지막 Tag), Attribute의 `groupState`가 전부 이 이유로
|
||||
삭제됨. 새로 `Relate`를 하나 만들고 싶어지면 **먼저 "이거 그냥 클로저가
|
||||
캡처하면 되는 것 아닌가?"를 물어볼 것.**
|
||||
|
||||
**써야 할 것 — 하나의 클로저 수명을 넘어서는 상태**:
|
||||
- **여러 위치가 하나의 자원을 공유**할 때의 참조 카운트 — `Tag`의
|
||||
`tagNameMap`(이름 → 그 이름을 걸고 있는 위치 집합).
|
||||
- **여러 `process` 호출을 가로지르는 dedup 기록** — `Ref`의 "이 자리에
|
||||
마지막으로 바인딩한 Ref"(클로저의 `hintValue`는 *다음* 값이지 *이전*
|
||||
값이 아니라서 캡처로 대체 불가).
|
||||
- **소유권/멤버십 전역 판정** — `Slot`의 `elementOwner`.
|
||||
- **"언제까지 실행돼도 되는가"** — `bindLifetime`/`canExecute`
|
||||
(`base/lifecycle-pattern.md`). 애초에 클로저 수명과 무관한 질문.
|
||||
|
||||
**쓸 때 같이 지킬 것**:
|
||||
- **정리 조건을 실제 정리와 묶을 것.** `Relate` 엔트리를 지우는 코드가
|
||||
"실제로 물러날 때"라는 조건 **밖**에 있으면, spurious 재발행에서
|
||||
기록만 날아가 dedup이 조용히 무력화됨 — `RefLeafHandler`가 실제로 이
|
||||
버그를 냈음(`bind-system-plan.md` "`Ref`의 retract" 절).
|
||||
- **weak하다고 "언젠간 알아서 사라진다"에 기대지 말 것.** 값이 `SetWeak`
|
||||
이어도 **언제 사라지는지는 GC 타이밍**이라, 그 전에 같은 키를 다시
|
||||
쓰려는 코드가 비결정적으로 실패함 — `Slot`의 `destroySlotTree`가 자식
|
||||
소유권을 명시적으로 반납하지 않고 GC에 맡기고 있던 게 이 사례
|
||||
(`base/slot-plan.md` "요소 소유권" 절). **명시적으로 만든 기록은
|
||||
명시적으로 지울 것.**
|
||||
- 서로 다른 두 `Relate`의 상호 강참조 순환 금지 — 위 "위험한 패턴" 절.
|
||||
|
||||
## 실제 구조 (확정, 2026-08-08 세션)
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
# Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨)
|
||||
|
||||
> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행
|
||||
> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나
|
||||
> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로
|
||||
> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델
|
||||
> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가
|
||||
> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z**
|
||||
> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로
|
||||
> 구현하면 옛 모델로 짜게 됨** — 반드시
|
||||
> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것.
|
||||
|
||||
**상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, retract=폐기)과
|
||||
소스 트리 상 패키지 경계까지 확정되어 `research/`에서 승격됨(`base/
|
||||
architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 원본:
|
||||
|
|
@ -229,7 +239,12 @@ process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는
|
|||
적용하는 것이라 더 정확함(위치 비교로는 "이 Slot이 동시에 다른 위치에도
|
||||
마운트돼 있는가"를 못 잡음).
|
||||
|
||||
**[GC 주의, 2026-08-12 열세 번째 세션] `kSlotMap`/`slotOwner` 둘 다
|
||||
**[GC 주의, 2026-08-12 열세 번째 세션 — 아래 문단은 이 결정에 이르게 된
|
||||
역사적 근거이고, 거기 나오는 `kSlotMap`/`slotOwner`는 지금은 둘 다
|
||||
존재하지 않음: `kSlotMap`은 2026-08-13 다섯 번째 세션에 Handler 계약이
|
||||
클로저 반환으로 바뀌며 삭제, `slotOwner`는 2026-08-12 열여섯 번째 세션에
|
||||
`elementOwner`로 일반화. 결론(= 전부 weak, 실제 앵커는 `bindLifetime`
|
||||
하나)만 아래 코드에 그대로 살아있음]** `kSlotMap`/`slotOwner` 둘 다
|
||||
Slot을 `SetStrong`으로 저장하면 안 됨 — `kSlotMap[inst][k]=slot`(강)과
|
||||
`slotOwner[slot]=inst`(강)가 동시에 있으면 **서로 다른 두 `Relate`가
|
||||
맞물려 서로를 살려주는 순환**이 생김(`inst`가 살아있어야 `slotOwner`가
|
||||
|
|
@ -266,21 +281,33 @@ local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값
|
|||
local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후
|
||||
-- 값이라 outer key 하나당 값 하나만 저장하고 싶어도 key가 필요함 —
|
||||
-- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고)
|
||||
local OWNER_POS = "__ownerPos" -- [2026-08-13 감사 신설] owner 안에서의 위치(top-level만 씀, 아래 참고)
|
||||
|
||||
-- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error
|
||||
local function claimOwner(element, ownerKey)
|
||||
if elementOwner:GetWeak(element, OWNER) ~= nil then
|
||||
error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지")
|
||||
end
|
||||
elementOwner:SetWeak(element, OWNER, ownerKey)
|
||||
end
|
||||
|
||||
-- top-level(`SlotHandler`) 전용 — "같은 (inst,k) 자리의 spurious 재발행"만
|
||||
-- false, 그 외 중복은 전부 error. 반환값 = 실제로 새로 클레임했는가.
|
||||
local function claimOwnerAt(element, inst, k)
|
||||
local current = elementOwner:GetWeak(element, OWNER)
|
||||
if current == ownerKey then
|
||||
return false -- 이미 같은 owner — no-op, 재확인만
|
||||
if current == inst and elementOwner:GetWeak(element, OWNER_POS) == k then
|
||||
return false -- 정확히 이 자리가 이미 들고 있음 — 재확인만, no-op
|
||||
end
|
||||
if current ~= nil then
|
||||
error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
|
||||
end
|
||||
elementOwner:SetWeak(element, OWNER, ownerKey)
|
||||
elementOwner:SetWeak(element, OWNER, inst)
|
||||
elementOwner:SetWeak(element, OWNER_POS, k)
|
||||
return true
|
||||
end
|
||||
|
||||
local function releaseOwner(element, ownerKey)
|
||||
-- [정정, 2026-08-13 세션] 불일치를 조용히 무시하지 않음 — claimOwner가 항상
|
||||
-- [정정, 2026-08-13 세션] 불일치를 조용히 무시하지 않음 — claim이 항상
|
||||
-- 먼저 성공해야만 이 element를 이 ownerKey가 들고 있을 수 있으므로, 여기서
|
||||
-- 불일치가 관측되는 것 자체가 호출측(rawRemove/rawExtract/SlotHandler.process가 반환한 클로저)의
|
||||
-- 소유권 bookkeeping이 어딘가 깨졌다는 뜻 — Dispatch의 "매치 실패는 조용한
|
||||
|
|
@ -290,10 +317,11 @@ local function releaseOwner(element, ownerKey)
|
|||
error("releaseOwner: 이 element는 이 ownerKey가 소유하고 있지 않음 — 호출측 소유권 추적이 깨졌음")
|
||||
end
|
||||
elementOwner:SetWeak(element, OWNER, nil)
|
||||
elementOwner:SetWeak(element, OWNER_POS, nil)
|
||||
end
|
||||
|
||||
function SlotHandler.process(inst, k, slotValue, index)
|
||||
if claimOwner(slotValue, inst) then
|
||||
if claimOwnerAt(slotValue, inst, k) then
|
||||
-- [정정, 2026-08-13 세션] bindLifetime을 attachSlot 밖, 여기(top-level Handler)로
|
||||
-- 이동 — 반환하는 클로저 쪽 unbindLifetime과 같은 층위(Handler)로 대칭. 중첩
|
||||
-- Slot은 여전히 anchor 불필요(자신을 담는 outer의 `_elements`(plain strong
|
||||
|
|
@ -303,14 +331,13 @@ function SlotHandler.process(inst, k, slotValue, index)
|
|||
bindLifetime(inst, slotValue)
|
||||
attachSlot(slotValue, inst, inst, k)
|
||||
end
|
||||
-- claimOwner가 false여도(이미 같은 Slot이 이 자리를 차지 중인 spurious 재발행)
|
||||
-- 아래 클로저는 항상 똑같이 올바르게 동작함 — attachSlot을 두 번 안 부르는
|
||||
-- 것만 위에서 갈렸을 뿐, "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 어느
|
||||
-- 쪽 process 호출이 반환하든 slotValue/inst가 같아서 동일함(2026-08-13 다섯
|
||||
-- 번째 세션 — 이래서 별도 `kSlotMap`이 더 이상 필요 없음: 나중에 이 인덱스가
|
||||
-- 통째로 철거될 때 실제로 불리는 건 항상 *가장 최근* process 호출이 반환한
|
||||
-- 클로저인데, 그게 어느 호출에서 왔든 캡처된 slotValue/inst는 늘 참값이라
|
||||
-- "누가 진짜로 attach했는지"를 별도로 기억해둘 필요 자체가 없음).
|
||||
-- 여기 도달했다는 건 "이 (inst,k) 자리가 slotValue를 소유 중"이 보장된 것 —
|
||||
-- 방금 새로 클레임했든, 직전 사이클의 클레임이 spurious 재발행을 거쳐 그대로
|
||||
-- 살아있든 둘 중 하나(다른 소유자였다면 claimOwnerAt이 이미 error). 어느 쪽이든
|
||||
-- "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 slotValue/inst가 같아서 동일하므로
|
||||
-- 클로저도 하나로 통일 — 여기서 no-op 클로저를 반환하면 안 됨: retractFrom은
|
||||
-- 클로저를 early-return시키든 말든 체인에서 항상 *소비*하므로, spurious 사이클에서
|
||||
-- no-op을 심어두면 그 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라짐.
|
||||
return function(hintValue)
|
||||
if slotValue == hintValue then return end -- 같은 Slot 재발행 → no-op
|
||||
destroySlotTree(slotValue) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용)
|
||||
|
|
@ -320,6 +347,51 @@ function SlotHandler.process(inst, k, slotValue, index)
|
|||
end
|
||||
```
|
||||
|
||||
**[전면 정정, 2026-08-13 감사] `claimOwner`를 nested/top-level 두 함수로
|
||||
쪼개고, top-level은 `(inst, k)`까지 본다.** 원래는 하나의 `claimOwner`가
|
||||
"같은 ownerKey면 `false` 반환(no-op)"을 양쪽에 공유했는데, 그 분기가
|
||||
**두 개의 서로 다른 상황을 구분 못 해서** 양쪽 경로에 각각 버그를
|
||||
만들고 있었음:
|
||||
|
||||
- **top-level**: `Frame { slot, slot }`(같은 Slot을 같은 inst의 두 위치에)에서
|
||||
`k=2`가 `current == inst`라 조용히 `false`를 받아 attach를 건너뛰는데,
|
||||
**그러고도 파괴적인 클로저를 반환**했음 — 나중에 철거될 때
|
||||
`destroySlotTree`가 두 번 돌고 `unbindLifetime` 짝이 어긋나며
|
||||
`releaseOwner`가 (이미 해제된 상태라) error로 터짐. 구 설계는
|
||||
`kSlotMap`에 안 적힌 자리는 `retract`가 자연히 no-op이라 우연히 막혀
|
||||
있었는데, `kSlotMap` 제거가 이 방어를 같이 걷어낸 회귀였음. 이제
|
||||
`claimOwnerAt`이 `k=2`에서 곧바로 error를 내므로 그 상태 자체가 안
|
||||
만들어짐 — **클로저를 두 갈래로 쪼개는 방식으로는 못 고침**: `retractFrom`은
|
||||
클로저가 early-return하든 말든 체인에서 항상 소비하므로, spurious
|
||||
사이클에서 no-op 클로저를 심으면 다음 진짜 교체 때 이전 서브트리를
|
||||
정리할 주체가 사라져 오히려 더 큰 누수가 됨(감사 중 실제로 그 방향을
|
||||
먼저 써봤다가 되돌림).
|
||||
- **nested**: `rawAdd`는 애초에 `claimOwner`의 반환값을 안 봄(아래 "요소
|
||||
소유권" 절 호출부) — 그래서 `local a = Slot{}; Slot { a, a }`가 조용히
|
||||
통과해 `_elements = {a, a}`가 되고, `attachSlot`이 같은 Slot에 대해 두 번
|
||||
불려 부모 `lengthList`에 같은 Slot이 두 번 계산되고 `slot.Offset`도
|
||||
두 번째 호출이 덮어써 첫 번째 `Source`가 고아가 됨.
|
||||
|
||||
**해법의 근거 — nested엔 "재클레임"이라는 개념이 애초에 없다.** `:List`의
|
||||
`reconcile`은 요소를 바꿀 때 항상 `rawRemove(prev)`(→`releaseOwner`) 다음에
|
||||
`rawAdd(result)`(→`claimOwner`) 순서로 부르고(아래 "구현" 절 의사코드),
|
||||
`rawMove`/`rawSwap`은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가
|
||||
갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 **무조건
|
||||
error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으로
|
||||
`process`가 다시 불리는 게 정상 경로라 그 케이스만 구분해줘야 하고,
|
||||
그러려면 owner 하나로는 부족해서 위치(`k`)까지 봐야 함.
|
||||
|
||||
**위치를 키에 넣어도 안전한 이유(사용자 확인)** — 여기서 쓰는 `k`는
|
||||
"바깥 컨테이너 안에서의 인덱스"고, 이건 nested Slot의 `Length`가 변해도
|
||||
바뀌지 않음. 실제 물리 배치의 변동은 전부 `offset`이 흡수하도록 설계돼
|
||||
있음(`base/bind-system-plan.md` "Length/Offset" 절의 `recompute`가 그
|
||||
증거 — `lengthList`/`sourceList`는 위치별 배열이고 순서 계산만
|
||||
누적합으로 함). top-level의 `k`는 특히 props 배열 리터럴의 위치라
|
||||
저작 시점에 고정. **nested는 `Move`/`Swap`/`Splice`/`Remove`가
|
||||
`_elements` 인덱스를 실제로 밀고 당기지만, 위 결론대로 nested는
|
||||
위치를 아예 안 쓰므로(엄격 `claimOwner`) 무관** — 그래서
|
||||
`OWNER_POS`는 top-level 전용이고 `rawAdd` 경로는 건드릴 필요가 없음.
|
||||
|
||||
`attachSlot` 자체가 quad-roblox 소속이라 `inst`를 아는 건 자연스러움 —
|
||||
`elementOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간
|
||||
표현 테이블이어도) 무관하게 동작함, 그냥 "지금 이 자리를 차지한 값이
|
||||
|
|
@ -346,12 +418,34 @@ Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의
|
|||
|
||||
```lua
|
||||
-- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리
|
||||
claimOwner(element, self) -- self = 담는 Slot. 이미 다른 곳 소유면 여기서 error
|
||||
claimOwner(element, self) -- self = 담는 Slot. 이미 누가(같은 self 포함) 소유 중이면 여기서 error
|
||||
|
||||
-- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리
|
||||
releaseOwner(element, self)
|
||||
|
||||
-- destroySlotTree(slot) 안, 자식들을 파괴하기 직전(위 "파괴" 절)
|
||||
releaseOwner(element, slot)
|
||||
```
|
||||
|
||||
**[2026-08-13 감사] `claimOwner`는 반환값이 없음 — 성공 아니면 error다.**
|
||||
예전 버전은 "이미 같은 owner면 `false` 반환"이었고 `rawAdd` 호출부는 그
|
||||
반환값을 아예 안 봐서, `local a = Slot{}; Slot { a, a }`가 조용히 통과했음
|
||||
(위 "Slot과 Store 바인드의 관계" 절의 전면 정정 참고). nested 경로엔
|
||||
재클레임이 정당한 경우가 하나도 없으므로(reconcile은 항상 `rawRemove`→
|
||||
`rawAdd` 순서, `rawMove`/`rawSwap`은 클레임 미접촉) 무조건 error가 맞음 —
|
||||
top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함.
|
||||
|
||||
**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨.** `elementOwner`는
|
||||
값도 `SetWeak`이라 "파괴된 Slot이 아무에게도 참조되지 않게 되면 소유권
|
||||
기록도 저절로 사라진다"가 원리적으로는 맞지만, **그게 언제인지가 GC
|
||||
타이밍에 달려 있어서** 그 전에 같은 element를 다른 곳에 넣으려 하면
|
||||
"이미 마운트돼 있음" error가 비결정적으로 터짐(사용자가 직접 들고 있는
|
||||
nested `Slot`을 파괴 후 재사용하는 경로가 정확히 이 케이스). 그래서
|
||||
`rawRemove`/`rawExtract`뿐 아니라 **`destroySlotTree`도 자기 자식들의
|
||||
`releaseOwner`를 명시적으로 부름** — 원래 이 함수는 소유권을 전혀 안
|
||||
건드리고 있었음. top-level Slot 자신의 반납은 `SlotHandler.process`가
|
||||
반환한 클로저가 담당(층위 분리는 `unbindLifetime`과 동일한 원칙).
|
||||
|
||||
`ownerKey`가 `inst`(top-level)든 `Slot`(nested)든 `elementOwner`는
|
||||
타입을 신경 안 써서 하나의 레지스트리로 충분 — `outerSlot`이 값으로
|
||||
들어가도 `elementOwner` 자체는 아무것도 강하게 안 붙잡고(전부
|
||||
|
|
@ -1298,6 +1392,9 @@ Slot=그 `.Length`)이 됨. plain 요소만 있는 흔한 경우엔 항상 합==
|
|||
```lua
|
||||
local function destroySlotTree(slot)
|
||||
for i, element in ipairs(slot._elements) do
|
||||
-- [정정, 2026-08-13 감사] 소유권 반납을 먼저 — 아래 "소유권 반납은
|
||||
-- GC에 맡기면 안 됨" 참고
|
||||
releaseOwner(element, slot)
|
||||
if isSlot(element) then
|
||||
destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음
|
||||
else
|
||||
|
|
@ -1310,6 +1407,10 @@ local function destroySlotTree(slot)
|
|||
unbindLifetime(slot._mountedInst, observer)
|
||||
end
|
||||
end
|
||||
-- [정정, 2026-08-13 감사] 마운트 상태도 되돌림 — 안 그러면 파괴된 Slot이
|
||||
-- `_mounted == true`로 남아 "마운트된 Slot의 재마운트는 즉시 throw"(위 절)에
|
||||
-- 영원히 걸리고, `_mountedInst`가 죽은 inst를 계속 강하게 붙잡음.
|
||||
slot._mounted, slot._mountedInst = false, nil
|
||||
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
|
||||
-- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
|
||||
-- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번.
|
||||
|
|
@ -1326,6 +1427,11 @@ function rawRemove(self, index)
|
|||
if bk.observers[index] then
|
||||
unbindLifetime(self._mountedInst, bk.observers[index]) -- outer가 이 위치 위해 등록해둔 observer
|
||||
end
|
||||
releaseOwner(element, self) -- [정정, 2026-08-13 감사] 원래 이 줄이 의사코드에서
|
||||
-- 빠져 있었음(산문 쪽 "요소 소유권" 절은 rawRemove/
|
||||
-- rawExtract가 releaseOwner를 부른다고 이미 명시하고
|
||||
-- 있었는데 코드만 불일치) — 엄격 releaseOwner가
|
||||
-- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
|
||||
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
|
||||
|
||||
spliceArraysDown(self, index) -- _elements/lengthList/sourceList 전부 한 칸씩 당김
|
||||
|
|
@ -1483,6 +1589,246 @@ return Slot {
|
|||
쓸 수 있음** — Slot-in-Slot 중첩(위 절)과 이 반응형 raw 요소가 서로
|
||||
독립적으로 조합됨을 보여주는 예.
|
||||
|
||||
### `State<Slot>` 재설정 시 소유권이 안전한가 — 확인됨 (2026-08-13 감사, 사용자 질문)
|
||||
|
||||
**질문**: `Slot { state<Slot> }`에서 그 state가 다른 Slot으로 재설정되면
|
||||
소유권/마운트 상태가 정확히 갈리는가. 그리고 이걸 "래퍼가 불변이라
|
||||
괜찮다"로 설명할 게 아니라 **Slot-in-Slot 자체가 일반적으로 안전해야**
|
||||
하는 것 아닌가(사용자 판단: 후자가 맞음).
|
||||
|
||||
**결론: 후자가 맞고, 실제로 성립함.** 위 sugar 때문에 구조는 항상
|
||||
`outer._elements[i] = sub`(래퍼 Slot, 이 위치에 **영구 고정**) →
|
||||
`sub:Single(state)` → 그 `:List`의 `reconcile`이 안쪽만 교체 — 이고,
|
||||
`reconcile`은 `result ~= prev`일 때 **`rawRemove(self, prev)` 다음에
|
||||
`rawAdd(self, result, pos)`** 순서로 부름(위 "구현" 절). 즉 소유권이
|
||||
**반납 → 재클레임** 순서로 정확히 갈림:
|
||||
|
||||
| 시점 | `elementOwner[innerA]` | `elementOwner[innerB]` |
|
||||
|---|---|---|
|
||||
| 최초 reconcile | `sub` | — |
|
||||
| state가 B로 재설정, `rawRemove(sub, innerA)` | `nil`(releaseOwner) | — |
|
||||
| 이어서 `rawAdd(sub, innerB, pos)` | `nil` | `sub`(claimOwner) |
|
||||
|
||||
바깥(`outer`) 입장에선 `_elements[i]`가 계속 `sub`라 아무 일도 안
|
||||
일어나고, 소유권 판정도 `sub` 아래 한 레벨에서만 갈림 — **래퍼가
|
||||
불변이라는 사실에 기대는 게 아니라, nested CRUD의 release→claim 규율
|
||||
자체가 안전성을 만듦.** 그래서 `Slot { Slot { Slot } }`처럼 손으로
|
||||
중첩한 경우에도 정확히 같은 규칙 하나로 동작함(래퍼 sugar는 그저 그
|
||||
일반 메커니즘의 사용자일 뿐).
|
||||
|
||||
**단 이 결론은 위 "요소 소유권" 절의 2026-08-13 감사 수정 셋을 전제함** —
|
||||
(1) nested `claimOwner`가 엄격(같은 owner 재클레임도 error)이라
|
||||
`Slot { a, a }`가 실제로 막히고, (2) `rawRemove`가 `releaseOwner`를
|
||||
실제로 부르고(의사코드에서 빠져 있었음), (3) `destroySlotTree`가 자식
|
||||
소유권을 GC에 안 맡기고 명시적으로 반납. 셋 중 하나라도 빠지면 이 표의
|
||||
중간 단계가 어긋남.
|
||||
|
||||
### [전면 정정, 2026-08-13 여섯 번째 세션 후속, 사용자 결정] `State<Slot>` 교체는 **파괴가 아니라 언마운트** — `state<Frame>`와 완전히 동일
|
||||
|
||||
아래 두 절("`State<Slot?>`가 `nil`이 됐다 돌아오는 경우", "포탈")은 당시
|
||||
설계(reconcile이 `rawRemove`=파괴를 씀)를 정확히 서술한 것이지만, 그
|
||||
설계 자체가 이 정정으로 **뒤집힘**. 두 절은 문제 제기 과정으로만 남겨두고,
|
||||
현재 유효한 규칙은 이 절이다.
|
||||
|
||||
**결정(사용자)**: `State<Slot>`이 다른 값으로 교체될 때 이전 Slot은
|
||||
**파괴되지 않고 언마운트만 된다.** 근거:
|
||||
|
||||
1. **`state<Frame>`가 이미 그렇게 동작함** — `store.child:Set(otherFrame)`을
|
||||
해도 이전 `Frame`을 quad가 `Destroy()`해주지 않음, 그냥 트리에서
|
||||
내려올 뿐. `State<Slot>`만 다르게(파괴로) 동작할 이유가 없음. **"이전
|
||||
값을 지울지는 그 값을 만든 쪽이 정한다"**는 이미 `Ref`("Destroy와 무관")/
|
||||
`Attribute`("명시적 `None`으로만 지움")에서 확정된 quad 전역 철학과도
|
||||
같은 결.
|
||||
2. **비파괴 추출은 이미 지원되는 개념** — `Extract`/`ExtractAll`/`Splice`가
|
||||
전부 비파괴로 확정돼 있음(위 "CRUD API 확정" 절). "제거 = 파괴"만
|
||||
reconcile이 임의로 골랐던 것이라, 그 선택을 되돌리는 것뿐 새 능력이
|
||||
아님.
|
||||
3. **뽑아냈으면 더 이상 leaf의 소유가 아님** — 소유권이 반납된
|
||||
(`releaseOwner`) 상태이므로, 그 Slot을 계속 쓸지 버릴지는 그걸 들고
|
||||
있는 코드의 몫.
|
||||
|
||||
**그러면 안 지운 Slot은 언제 죽는가 — "들고 있다 죽으면 같이 소멸"**
|
||||
(GC-native, 이 프로젝트의 기본 원칙 그대로): 아무도 참조를 안 들고 있으면
|
||||
그냥 GC됨. 명시적으로 지금 죽이고 싶으면 `dispose`(아래).
|
||||
|
||||
**부수 효과 — 이미 파괴된 대상에 재마운트하려는 시도가 자연히 막힘.**
|
||||
Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 물리 target에
|
||||
묶고, 실제 동작 전에 `canExecute`를 확인**하도록 하면(`base/lifecycle-pattern.md`),
|
||||
"nested로 마운트해둔 뒤 물리 Instance를 Destroy하고, 그 다음 Slot을 뽑아
|
||||
다른 데 쓰려는" 경로가 별도 방어 로직 없이 걸러짐 — 이미 있는
|
||||
`bindLifetime`/`canExecute` 게이트를 한 층 더 촘촘히 적용하는 것뿐,
|
||||
새 메커니즘이 아님.
|
||||
|
||||
**`Set`으로 덮어쓰기 *전에* 이전 값을 직접 `Destroy()`하는 건 UB.**
|
||||
`state<Frame>`에서 `frame:Destroy()`를 먼저 하고 `Set(other)`을 부르는
|
||||
것과 정확히 같은 문제 — quad는 그 값이 이미 죽었다는 걸 모른 채 언마운트
|
||||
경로를 탐. 순서는 항상 **`Set`(언마운트) → 그 다음 정리**.
|
||||
|
||||
#### `dispose(any)` — quad가 만든 것을 안전하게 지우는 유일한 경로 (신설, 사용자 제안)
|
||||
|
||||
위 UB를 "조심하세요"로만 두지 않기 위해, **base 레벨 탑레벨 유틸
|
||||
`dispose(value)`** 를 제공하는 방향으로 확정:
|
||||
|
||||
- 의미(**[정정, 사용자 확정]** 최초안은 "마운트돼 있으면 먼저 떼어낸 뒤
|
||||
파괴"였으나 더 단순하게 확정): **대상이 아직 어느 트리에 의해 살아있길
|
||||
요구되고 있으면 파괴를 거부하고 즉시 `error`.** 떼어내주지 않음 —
|
||||
떼어내는 건 `Set`(언마운트)의 몫이고, `dispose`는 그 뒤에 부르는 것.
|
||||
아무도 요구하지 않는 상태면 실제로 파괴(Slot이면 하위까지 재귀).
|
||||
- **왜 거부가 맞는가(사용자)**: "실제로 클리어 하거나 Destroy 해도
|
||||
로블록스엔 에러 안 나는데, quad에선 데이터 구조가 깨지는 일이니까요."
|
||||
엔진은 조용히 넘어가지만 quad의 `_elements`/`lengthList`/`sourceList`/
|
||||
`elementOwner`는 그 순간 어긋남 — 그래서 **quad가 관리 중인 값을
|
||||
안전하게 지우는 유일한 경로가 `dispose`**이고, 그 경로가 "지금 지우면
|
||||
안 되는 상태"를 잡아주는 게 존재 이유. 위 "`Set` 전에 직접 `Destroy()`
|
||||
하는 건 UB" 항목이 `dispose`를 쓰면 UB가 아니라 **명확한 에러**가 됨.
|
||||
- **이게 성립하는 이유 — "이 값이 지금 어디 마운트돼 있는가"를 이미
|
||||
알고 있음.** `a = Frame{}; Frame{a}; Frame{a}`를 error로 잡기로 이미
|
||||
확정했고(위 "핵심 제약: 소유권 귀속과 단일 마운트"), 그 판정을 위해
|
||||
`elementOwner`가 element → owner를 들고 있음 — `dispose`는 그 정보를
|
||||
거꾸로 읽으면 되므로 **새 부기가 필요 없음**. 사용자 지적: "이미 두
|
||||
곳에 넣는 게 에러나도록 하기로 했으니, 어디 마운트되었냐가 따져지고,
|
||||
그래서 이미 가능한 일".
|
||||
- 네이밍/시그니처(`dispose(any)`가 맞는지, 타입을 어떻게 좁힐지),
|
||||
Slot 외의 대상(Instance/Observer/Effect)까지 커버하는 범위,
|
||||
`unbindLifetime`과의 역할 분담은 **미확정** — `.claude/question.md`에
|
||||
올림. 여기서는 "언마운트로 바꾼 대신 명시적 파괴 수단을 제공한다"는
|
||||
방향만 확정.
|
||||
|
||||
#### 구현상 바뀌어야 하는 것
|
||||
|
||||
`reconcile`이 교체/소멸 시 `rawRemove`(파괴) 대신 **비파괴 경로**를 타야
|
||||
하고, 그 경로는 `destroySlotTree`가 지금 하는 일 중 **파괴만 빼고 나머지는
|
||||
그대로 해야 함**(자식 observer `unbindLifetime`, 옛 owner에 등록해둔
|
||||
`Dispatch.setLength`/`setOffsetSource` 해제, `_mounted`/`_mountedInst`
|
||||
복원, `releaseOwner`).
|
||||
|
||||
**[정정, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음** — 옛 owner에
|
||||
대해 그냥 **`Dispatch.setOffsetSource(ownerKey, position, None)` +
|
||||
`Dispatch.setLength(ownerKey, position, 0)`을 다시 부르면 끝**.
|
||||
이건 이미 확정된 관용구 그대로임(`base/bind-system-plan.md`의 "실제
|
||||
마운트를 하지 않는 위치는 `None`을 등록 — `setLength`도 짝을 맞춰 `0`").
|
||||
즉 **해제 = 0/`None`으로 재등록**이고 별도 unregister 함수가 없어도 됨 —
|
||||
앞서 "이게 실제 작업량"이라고 적었던 판단은 과했음.
|
||||
|
||||
**⚠️ 순서가 중요함 — `setOffsetSource`를 먼저, `setLength`를 나중에
|
||||
(2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할
|
||||
때는 이 순서를 반드시 지켜야 함**:
|
||||
|
||||
- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`는
|
||||
`sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함
|
||||
(`base/bind-system-plan.md` "Length/Offset" 절).
|
||||
- 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는
|
||||
시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset
|
||||
`Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에
|
||||
`:Set()`이 날아가고, 그 Source를 구독하던 (이제 곧 없어질) 자식들의
|
||||
`LayoutOrder` 계산이 헛되이 캐스케이드됨. 사용자 표현대로 "**invalid한
|
||||
offset source 자체가 있다는 것부터 위험**".
|
||||
- `setOffsetSource(None)`을 먼저 부르면 그 자리는 `recompute`의
|
||||
`offset ~= None` 가드에 걸려 곧바로 제외되므로, 뒤이은 `setLength(0)`의
|
||||
`recompute`가 그 Source를 아예 안 건드림.
|
||||
- **자기 자신의 offset은 어차피 안 바뀜**(자기 length가 줄어든다는 건
|
||||
자기 앞 형제들의 누적합은 그대로라는 뜻) — 그래서 이 순서 문제는
|
||||
"내 offset이 틀리게 계산된다"가 아니라 **"죽는 중인 Source에 쓰기가
|
||||
날아간다"** 쪽임. 사용자도 "물론 별 상관 없어요"라고 했듯 실제 값
|
||||
오류로 이어지진 않지만, 방어적으로 순서를 고정.
|
||||
|
||||
**추가 방어 조치**:
|
||||
- **해제 시 `slot.Offset = nil`도 같이** — 이 문서의 "`Slot.Offset`은
|
||||
마운트 시점에 세팅되고 마운트 전엔 `nil`" 규칙과 짝을 맞춤. 안 그러면
|
||||
떼어낸 Slot이 옛 owner 기준의 stale한 `Offset`을 계속 공개해, 그걸
|
||||
읽는 사용자 코드가 조용히 틀린 값을 씀.
|
||||
- **`recompute`는 `sourceList[i]`가 `None`이든 `nil`이든 "참여 안 함"으로
|
||||
똑같이 관대하게 넘어갈 것** — 정상 상태에선 항상 `None`으로 채워지는 게
|
||||
계약이지만(`nil`은 배열에 구멍을 냄), 해제/재마운트가 얽히는 전이
|
||||
구간에서 `nil`이 관측돼도 크래시 대신 skip이어야 함. 이건 계약 완화가
|
||||
아니라 **순수 방어** — 등록 쪽은 여전히 `None`을 쓸 의무가 있음.
|
||||
|
||||
**`state<state<Frame>>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된
|
||||
것"으로 수용**(사용자 판단) — `state<state<Tag>>`와 같은 범주로,
|
||||
평탄화 도구(`research/operator-sugar-plan.md`)가 처리할 요소이고 실사용
|
||||
케이스가 드묾. Dispatch/Slot에 별도 배관을 넣지 않음.
|
||||
|
||||
### `State<Slot?>`가 `nil`이 됐다 돌아오는 경우 — 소유권은 정상, 단 **Slot은 파괴된다** (2026-08-13 여섯 번째 세션, 사용자 질문 — **위 정정으로 뒤집힘, 문제 제기 기록으로만 보존**)
|
||||
|
||||
**질문**:
|
||||
```lua
|
||||
local stateSlot: State<Slot?> = Source()
|
||||
Frame { Slot { stateSlot } }
|
||||
```
|
||||
에서 `stateSlot`이 `nil`로 지워졌다 다시 나타나도 문제 없는가.
|
||||
|
||||
**소유권 bookkeeping은 정상** — `:Single`이 감싼 `:List`의 `reconcile`이
|
||||
`data`가 빈 배열이 되면 직전 사이클 key(`true`)가 `seen`에 없으므로
|
||||
`rawRemove(sub, prev)`를 부르고(→ `releaseOwner`), 다시 값이 오면
|
||||
`rawAdd`(→ `claimOwner`)를 부름. 위 감사 수정(=`rawRemove`가 실제로
|
||||
`releaseOwner`를 부르고, `destroySlotTree`가 `_mounted`/`_mountedInst`도
|
||||
되돌림) 이후로는 재클레임이 깨끗하게 됨.
|
||||
|
||||
**하지만 그 사이에 Slot 자체가 파괴됨 — 이게 실질적인 답이다.**
|
||||
`reconcile`이 쓰는 건 `rawRemove`(= 제거 **+ 파괴**)이지 `rawExtract`(=
|
||||
비파괴)가 아님(위 "구현" 절, "제거는 항상 파괴 확정이라 `Extract` 아닌
|
||||
`Remove` 경로"). 그래서 `nil`이 된 순간 `destroySlotTree(slotA)`가 돌아
|
||||
**slotA의 자식 Instance들이 `:Destroy()`됨**. 다시 나타날 때 같은
|
||||
`slotA` 객체를 넣으면 `_elements`엔 이미 죽은 Instance 참조가 남아있는
|
||||
상태로 재마운트되는 것 — 껍데기만 살아있다. 이건 이미 확정된 정책
|
||||
("retract/재바인드되는 slot은 옮겨지지 않고 그냥 폐기된다", 위 "확정"
|
||||
절)의 직접적인 귀결이고 이번 감사로 바뀐 게 아님.
|
||||
|
||||
**따라서 실용적 관용구는 "Slot을 껐다 켜는" 게 아니라, Slot은 계속 두고
|
||||
그 *내용*을 비우는 것**(`Slot:Clear()`, 또는 `:List`의 `data`를 빈
|
||||
배열로) — 또는 매번 새로 만든 Slot을 `Set`하는 것. 같은 Slot 객체를
|
||||
`nil`↔`slotA`로 왕복시키는 코드는 두 번째 등장부터 조용히 빈/깨진
|
||||
서브트리를 냄.
|
||||
|
||||
### 포탈(`Extract`로 살아있는 Slot을 다른 곳으로 옮기기) — **해결됨**(위 "언마운트" 정정으로), 아래는 그 결론에 이른 과정
|
||||
|
||||
**질문**: `stateSlot:Get()`으로 Slot을 뽑아두고 → `Set()`으로 다른 Slot을
|
||||
넣고 → 뽑아둔 Slot을 다른 곳에 넣을 수 있는가. 가능하다면 "포탈"
|
||||
(위 "확정" 절에서 오버엔지니어링으로 미뤄둔 React portal류)이 이걸로
|
||||
해결됨.
|
||||
|
||||
**현재 설계로는 불가** — 위 절과 같은 이유 하나 때문임: `reconcile`이
|
||||
교체 시 `rawRemove`(파괴)를 부르므로, `Get()`으로 뽑아둔 레퍼런스는
|
||||
`Set()` 직후 **이미 파괴된 Slot**이 됨. 막는 게 소유권 규칙이 아니라
|
||||
"제거 = 파괴"라는 reconcile의 선택 하나라는 점이 중요함.
|
||||
|
||||
**그런데 나머지 부품은 이미 다 있음** — 그래서 사용자 지적대로 이
|
||||
방향은 실현 가능성이 높음:
|
||||
- `Extract`/`ExtractAll`/`Splice`가 이미 **비파괴 제거**로 확정돼 있음
|
||||
(위 "CRUD API 확정" 절) — 필요한 시맨틱이 이미 공개 API에 존재.
|
||||
- `claimOwner`/`releaseOwner`가 이미 소유권을 정확히 이양함 — 뽑힌
|
||||
Slot은 owner 없는 상태가 되고, 다른 곳에서 `Add`하면 깨끗이 클레임됨.
|
||||
- `attachSlot`은 **재마운트를 구조적으로 이미 지원** — `_mounted`/
|
||||
`_mountedInst`를 새 target으로 세팅하고 `_elements`를 새 물리 부모로
|
||||
flush하는 순수 구조 로직이라, 자식이 파괴만 안 됐다면 그대로 옮겨감.
|
||||
(`destroySlotTree`가 `_mounted`를 되돌리도록 한 이번 감사 수정이
|
||||
마침 이 경로의 전제 조건이기도 함.)
|
||||
|
||||
**남는 숙제(그래서 지금 확정 안 하고 열어둠)**:
|
||||
1. **어느 경로가 비파괴가 되어야 하는가** — `reconcile` 전체를
|
||||
`rawExtract`로 바꾸면 "안 쓰는 서브트리가 조용히 안 죽는" 누수가 되기
|
||||
쉬움. 사용자가 명시적으로 고르는 opt-in(예: `:Single`/`:List`의
|
||||
옵션, 또는 "이 Slot은 portal 대상"이라는 표식)이 맞아 보임 — Attribute
|
||||
자동 unset을 opt-in 유틸로 미뤄둔 것과 같은 결.
|
||||
2. **`destroySlotTree`가 하는 나머지 일들의 짝** — 자식 observer
|
||||
`unbindLifetime`, `Dispatch.setLength`/`setOffsetSource`로 옛 owner에
|
||||
등록해둔 항목 정리. 비파괴 경로는 이것들을 "해제 후 새 owner에 재등록"
|
||||
해야 하는데, 지금 `attachSlot`은 등록만 하고 해제하는 짝이 없음.
|
||||
3. **`Extract` 후 재마운트 전까지의 중간 상태** — 소유자 없이 물리
|
||||
트리에서도 떨어진 Slot이 얼마나 오래 떠 있어도 되는지(GC 앵커가
|
||||
`bindLifetime` 하나뿐인데 top-level에서 뽑히면 그 앵커도 풀림 →
|
||||
사용자가 레퍼런스를 안 들고 있으면 그냥 GC됨. 이건 오히려 자연스러운
|
||||
동작일 수 있음).
|
||||
|
||||
**결론**: "포탈은 별도 메커니즘이 필요하다"는 기존 전제는 **틀렸음** —
|
||||
`Extract` 비파괴 경로 하나로 나옴. **[사용자 결정, 위 "언마운트" 정정]**
|
||||
게다가 opt-in이 아니라 **기본 동작**이 됨: `State<Slot>` 교체는 원래부터
|
||||
`state<Frame>`와 같이 언마운트여야 했다는 판단이라, "포탈"은 별도
|
||||
기능이 아니라 그 결정의 자연스러운 귀결일 뿐. 위 숙제 1번(어디에
|
||||
opt-in할지)은 그래서 사라졌고, **2번(등록의 해제 짝)만 실제 작업으로
|
||||
남음** — 3번(중간 상태)은 "아무도 안 들고 있으면 GC, 명시적으로 지우려면
|
||||
`dispose`"로 답이 나옴.
|
||||
|
||||
**실측 필요 — 새 항목 아님, 기존 실측 목록에 흡수**: `State<T>`/`Slot<T>`
|
||||
자기 참조 제네릭 타입체크는 이미 M0/M6 실측 목록에 있던 것과 같은 급이라
|
||||
별도 항목 추가 안 함.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
# Tag — array-part 값 객체, `CollectionService` 얇은 래퍼
|
||||
|
||||
> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행
|
||||
> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나
|
||||
> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로
|
||||
> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델
|
||||
> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가
|
||||
> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z**
|
||||
> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로
|
||||
> 구현하면 옛 모델로 짜게 됨** — 반드시
|
||||
> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것.
|
||||
|
||||
**상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구
|
||||
모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존).
|
||||
2026-08-12 열한 번째 세션에 `TagHandler`의 `process`/`retract` 메커니즘을
|
||||
|
|
@ -23,6 +33,7 @@ Tag(name1, name2, ...) -- 생성자, 가변인자. Tag() 빈
|
|||
tag:Added(name: string | {string}): Tag -- clone 후 이름(들) 추가, 원본 안 건드림
|
||||
tag:Removed(name: string | {string}): Tag -- clone 후 이름(들) 제거
|
||||
tag:Contains(name): boolean -- 멤버십 확인
|
||||
tag:Names(): iterator<string> -- 담고 있는 이름 순회(아래 "메커니즘" 절이 쓰는 것)
|
||||
tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴)
|
||||
Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의
|
||||
Overridden(필드 단위 덮어쓰기, 손실 있음)와
|
||||
|
|
@ -95,8 +106,9 @@ nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라
|
|||
|
||||
1. **`retract`는 실제로 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상
|
||||
불림** — `bind-system-plan.md`의 "확정된 디스패치 모델" 절이 처음부터
|
||||
말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 `Dispatch.retractUnder`를
|
||||
부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음
|
||||
말해온 대로 `StoreBind`가 재-dispatch 전에 무조건
|
||||
`Dispatch.retractFrom`(2026-08-13 다섯 번째 세션 전까지의 이름은
|
||||
`retractUnder`)을 부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음
|
||||
(상세 근거는 `bind-system-plan.md` 일반 retract 계약 절, `archive/
|
||||
retract-always-fires-reversed.md`).
|
||||
2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수
|
||||
|
|
@ -186,6 +198,16 @@ end
|
|||
이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 등록).
|
||||
결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만
|
||||
일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성.
|
||||
**[범위 한정, 2026-08-13 감사] 이 깜빡임 방지는 "이 Tag를 바로 위에서
|
||||
직속으로 위임한 한 단계"에서만 성립함** — `Dispatch.retractFrom(inst,
|
||||
k, index, v)`는 힌트 `v`를 정확히 `index` 자리에만 넘기고 그보다 깊은
|
||||
인덱스에는 `nil`을 넘기기 때문(`bind-system-plan.md` "Dispatch 체인"
|
||||
절의 `retractFrom` 의사코드). 그래서 `State<State<Tag>>`에서 **바깥**
|
||||
store가 재발행하면 TagHandler(더 깊은 인덱스)는 `hintValue=nil`을
|
||||
받아 이름 전부를 실제로 `RemoveTag`했다가 새 체인이 다시 `AddTag`함.
|
||||
구조상 불가피함(바깥 단계는 안쪽이 결국 어떤 Tag를 내놓을지 모름)
|
||||
이고, 흔한 경로(`State<Tag>` 한 겹)는 영향 없음 — 실사용에서 문제가
|
||||
되면 그때 힌트를 깊은 인덱스까지 전파하는 안을 재검토.
|
||||
- **`Tag(A)→nil`**: `A`의 클로저가 `hintValue=nil`로만 불림(값이 `Tag`가
|
||||
아니게 돼 `process`는 매치 자체가 안 됨) — `hintIsTag=false`라 힌트가
|
||||
항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨
|
||||
|
|
|
|||
|
|
@ -67,8 +67,10 @@ Tween(opts: {Value: T, Time: number?, Style: Enum.EasingStyle?, ...}) -> Tween<T
|
|||
Lua 문법상 `Tween{Value=target, Time=0.3}`처럼 괄호를 생략해 호출. 정확한
|
||||
필드 목록은 아래 "확정: `Tween{...}` 최종 모양" 절 참고.
|
||||
|
||||
**PropertyHandler.process(inst,k,realv)의 새 로직** — `realv`는 이미
|
||||
StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값:
|
||||
**PropertyHandler.process(inst,k,realv,index)의 새 로직** — `realv`는 이미
|
||||
StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값(그리고 이 함수는
|
||||
계약대로 마지막에 no-op 클로저 `function() end`을 반환 — 아래 "왜
|
||||
`retract`가 더 이상 필요 없는가" 절):
|
||||
|
||||
1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고,
|
||||
`hasBeenSet` 여부만 갱신하고) 즉시 세팅.
|
||||
|
|
@ -150,10 +152,11 @@ StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값:
|
|||
|
||||
**GC-안전성은 기존과 동일** — `Relate`가 `inst`로 weak-keyed되어 있어
|
||||
`inst`가 죽으면 이 슬롯(엔진 Tween 객체+`Value` 포함)도 별도 정리 로직
|
||||
없이 같이 GC됨. **[정정, 2026-08-12 열한 번째 세션]** `retract`는 store
|
||||
없이 같이 GC됨. **[정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째
|
||||
세션에 클로저 반환 계약으로 서술 갱신]** `process`가 반환한 클로저는 store
|
||||
재발행마다 항상 불리지만(`bind-system-plan.md` 일반 retract 계약 절
|
||||
정정분 — "거의 안 불림"이었던 원 서술은 틀렸음), PropertyHandler의
|
||||
`retract`는 몸체가 no-op이라 실질적으로 하는 일이 없음 — 아래 절 참고.
|
||||
그 클로저는 몸체가 no-op이라 실질적으로 하는 일이 없음 — 아래 절 참고.
|
||||
|
||||
### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화
|
||||
|
||||
|
|
@ -164,9 +167,11 @@ StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값:
|
|||
값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — "핸들러 *타입*이
|
||||
바뀌는" 시나리오 자체가 Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위
|
||||
3-상태 저장 로직으로 PropertyHandler 내부에서 처리. (PropertyHandler의
|
||||
`retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은
|
||||
예외 없는 일반 규칙 — **매번 불리긴 하지만** 몸체가 no-op이라 실질적
|
||||
동작이 없음, 일반 프로퍼티는 애초에 "unset" 개념이 없어서.)
|
||||
`process`도 **반환값(retractor 클로저) 자체는 여전히 내놔야 함** —
|
||||
"반환 생략 불가"는 예외 없는 일반 규칙 — **매번 불리긴 하지만** 몸체가
|
||||
no-op이라 실질적 동작이 없음, 일반 프로퍼티는 애초에 "unset" 개념이
|
||||
없어서. **[2026-08-13 다섯 번째 세션]** 예전엔 별도 `retract` 필드에
|
||||
대한 규칙이었던 것이 반환값 규칙으로 자리만 옮겨온 것.)
|
||||
|
||||
### 타입 대수: `T' = T | Tween<T>` — Modifier/State/Source에 새 타입 기계 불필요
|
||||
|
||||
|
|
@ -179,7 +184,7 @@ State<T | Tween<T>>`가 나옴 — Modifier/State/Source/StoreBind 코드엔
|
|||
해석하는 코드는 여전히 PropertyHandler 하나에만 존재.
|
||||
|
||||
**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween<T>`는 `Ref`/`Observer`/
|
||||
`Slot`류처럼 `process`/`retract`를 가진 dispatch 참가자가 아니라 `None`/
|
||||
`Slot`류처럼 dispatch 참가자(`process`를 가진 Handler에 매칭되는 값)가 아니라 `None`/
|
||||
`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/
|
||||
`State<Modifier>`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에
|
||||
안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch
|
||||
|
|
|
|||
|
|
@ -53,9 +53,10 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/
|
|||
|
||||
이미 있는 pluggable Handler로 그대로 커버됨. `UICorner`/`UIPadding`/
|
||||
`UIScale` 같은 특수 키를 인식하는 Handler(`isHandlable`이 그 키를 매칭)가
|
||||
"이름 붙은 자식을 찾거나 만들고 프로퍼티 세팅"을 `process(inst, k, v)`에
|
||||
"이름 붙은 자식을 찾거나 만들고 프로퍼티 세팅"을 `process(inst, k, v, index)`에
|
||||
구현 — v1의 하드코딩 if/elseif 대신 정식 핸들러 계약(`isHandlable`/
|
||||
`priority`/`process`/`retract`)을 따르는 것만 다름. `modifier-plan.md`가
|
||||
`priority`/`process`, 2026-08-13 다섯 번째 세션 전까진 `retract`가 별도
|
||||
필드였음)을 따르는 것만 다름. `modifier-plan.md`가
|
||||
이미 예시로 든 `mod:UICorner(8)`은 이 특수 키를 flatten해서 props에
|
||||
꽂아넣는 사탕 문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고
|
||||
`Frame { UICorner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게
|
||||
|
|
@ -80,7 +81,7 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로
|
|||
않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을
|
||||
피하기 위함.
|
||||
|
||||
### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, `retract`는 관여 안 함 (2026-08-07 여덟 번째 세션)
|
||||
### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, 반환 클로저는 관여 안 함 (2026-08-07 여덟 번째 세션)
|
||||
|
||||
`modifier-plan.md`의 `None` 센티널(`base/bind-system-plan.md`의
|
||||
`NoneHandler` 재귀 재디스패치 절 참고)이 최종적으로 이 Handler의
|
||||
|
|
@ -89,14 +90,16 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로
|
|||
일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는
|
||||
쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨.
|
||||
|
||||
- **이건 `retract`가 아니라 `process` 자신의 로직** — **[정정, 2026-08-12
|
||||
열한 번째 세션]** `retract`는 store 재발행마다 항상 불리지만(핸들러
|
||||
타입이 안 바뀌어도, `bind-system-plan.md` 일반 retract 계약 절 정정분
|
||||
참고), 이 Handler는 `process(inst,k,v)` 자체가 `v`가 `nil`이든 숫자든
|
||||
전부 완결적으로 처리하므로(있으면 지우거나 만들거나) `retract`가 할 일이
|
||||
없어 no-op이면 충분 — 일반 프로퍼티 핸들러가 `retract`이 필요 없는 것과
|
||||
같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) 바뀌면 `process`가
|
||||
다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 구현할 게 없음.
|
||||
- **이건 반환 클로저가 아니라 `process` 자신의 로직** — **[정정, 2026-08-12
|
||||
열한 번째 세션, 2026-08-13 다섯 번째 세션에 클로저 반환 계약으로 서술
|
||||
갱신]** 그 클로저는 store 재발행마다 항상 불리지만(핸들러 타입이 안
|
||||
바뀌어도, `bind-system-plan.md` 일반 retract 계약 절 정정분 참고), 이
|
||||
Handler는 `process(inst,k,v,index)` 자체가 `v`가 `nil`이든 숫자든
|
||||
전부 완결적으로 처리하므로(있으면 지우거나 만들거나) 반환 클로저가 할 일이
|
||||
없어 `function() end`이면 충분 — 일반 프로퍼티 핸들러가 no-op 클로저를
|
||||
반환하는 것과 같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼)
|
||||
바뀌면 `process`가 다시 자식을 만들면 그만이라 클로저 쪽에 별도로
|
||||
구현할 게 없음.
|
||||
- **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를
|
||||
자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운
|
||||
API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable
|
||||
우선순위 스캔 포함)".
|
||||
|
||||
여기서는 다단 체인(retractUnder)까지는 다루지 않음 — 그건
|
||||
04-dispatch-chain-retractUnder.luau가 별도로 다룸(단일 owner 슬롯
|
||||
여기서는 다단 체인(retractFrom)까지는 다루지 않음 — 그건
|
||||
04-dispatch-chain-retractFrom.luau가 별도로 다룸(단일 owner 슬롯
|
||||
추적이 왜 깨지는지까지 포함). 이 파일은 "재귀 자체가 도는가", "우선순위
|
||||
스캔이 맞는 핸들러를 고르는가", "None -> nil 재디스패치가 다음
|
||||
핸들러로 자연히 좁혀지는가"까지만 검증.
|
||||
|
|
|
|||
306
.claude/luau-test/04-dispatch-chain-retractFrom.luau
Normal file
306
.claude/luau-test/04-dispatch-chain-retractFrom.luau
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
--[[
|
||||
검증 대상: 인덱스 기반 `Dispatch` 체인(2026-08-13 다섯 번째 세션 전면
|
||||
재설계, `.claude/base/bind-system-plan.md` "Dispatch 체인" 절)이 다단
|
||||
재귀 위임에서 실제로 정확한지.
|
||||
|
||||
새 모델의 요점(옛 모델과 뭐가 다른지):
|
||||
- `chains[inst][k]`는 **핸들러 배열이 아니라 `{[index] = retractor}`**.
|
||||
index = 같은 키 안에서의 재귀 깊이(같은 키 재귀는 index+1, 다른
|
||||
키로 위임하면 그 키에서 다시 1부터).
|
||||
- Handler 계약이 `process`/`retract` 2-메소드에서 **`process`가 자기
|
||||
retract 클로저를 반환하는 1-메소드**로 축소.
|
||||
- `Dispatch.retractFrom(inst,k,index,v)` 하나가 옛
|
||||
`retractUnder`/`retractSelfAndUnder` 둘을 대체(자기 포함/미만은
|
||||
호출자가 넘기는 인덱스로 표현).
|
||||
|
||||
**이 파일은 2026-08-13 감사에서 전면 재작성됨.** 이전 버전
|
||||
(`04-dispatch-chain-retractUnder.luau`)은 핸들러 **객체 identity**로
|
||||
체인을 추적하던 옛 설계와, 그 위에 얹혔던 "같은 (inst,k)에 같은 핸들러
|
||||
중복 push면 즉시 error" 가드를 검증하고 있었는데 — 그 가드는 인덱스
|
||||
기반 재설계로 **없어졌고**, `State<State<T>>`는 UB가 아니라 정상 지원
|
||||
대상으로 재정정됨. 즉 옛 파일은 설계와 정반대를 테스트하고 있었음.
|
||||
|
||||
검증 항목:
|
||||
A. 3단 체인(StoreBind -> StoreBind -> Property)이 인덱스 1/2/3으로
|
||||
안 겹치고 쌓이는가 (= `State<State<T>>` 정상 동작)
|
||||
B. 안쪽 store 재발행 시 index 3만 갈리고 1/2는 유지되는가
|
||||
C. 바깥 store 재발행 시 index 2/3이 **깊은 쪽부터** 정리되는가
|
||||
D. `retractFrom`의 hint(`v`)가 정확히 target index에만 전달되는가
|
||||
E. **[감사 회귀 테스트]** `chains:SetStrong`을 `handler.process`
|
||||
*뒤에* 두면 최초 마운트에서 하위 retractor가 유실되는가
|
||||
(음성 대조군 — 이 버그가 실재함을 증명, 2026-08-13 감사에서 발견)
|
||||
|
||||
실행: `luau 04-dispatch-chain-retractFrom.luau`
|
||||
|
||||
캐비엇: 아래 `subscribe(fn)`은 이 스파이크 전용 단순화 — 실제로는
|
||||
`state:Observer(fn)` + `bindLifetime`/`unbindLifetime` 조합
|
||||
(`.claude/base/lifecycle-pattern.md`). 다만 **이 스파이크의 retractor는
|
||||
no-op 스텁이 아니라 실제로 구독을 끊는다** — 옛 04번이 no-op print
|
||||
스텁이라 체인 파손을 못 잡는 사각지대였던 걸 그대로 반복하지 않기 위함.
|
||||
]]
|
||||
|
||||
local log = {}
|
||||
local function say(fmt, ...)
|
||||
local line = string.format(fmt, ...)
|
||||
table.insert(log, line)
|
||||
print(line)
|
||||
end
|
||||
|
||||
--==========================================================================
|
||||
-- 아주 작은 Store/State 스텁
|
||||
--==========================================================================
|
||||
|
||||
local StoreMT = {}
|
||||
StoreMT.__index = StoreMT
|
||||
|
||||
local function Store(value, name)
|
||||
return setmetatable({ _v = value, _subs = {}, _name = name }, StoreMT)
|
||||
end
|
||||
|
||||
local function isState(v)
|
||||
return type(v) == "table" and getmetatable(v) == StoreMT
|
||||
end
|
||||
|
||||
function StoreMT:Get()
|
||||
return self._v
|
||||
end
|
||||
|
||||
-- 구독. 반환값 = 구독 해제 함수(실제 구현의 unbindLifetime 자리)
|
||||
function StoreMT:subscribe(fn)
|
||||
local token = {}
|
||||
self._subs[token] = fn
|
||||
return function()
|
||||
self._subs[token] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function StoreMT:Set(v)
|
||||
self._v = v
|
||||
for _, fn in pairs(self._subs) do
|
||||
fn()
|
||||
end
|
||||
end
|
||||
|
||||
function StoreMT:subCount()
|
||||
local n = 0
|
||||
for _ in pairs(self._subs) do
|
||||
n += 1
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
--==========================================================================
|
||||
-- Dispatch — 인덱스 기반. storeFirst 플래그로 올바른 순서/버그 순서를 전환
|
||||
--==========================================================================
|
||||
|
||||
local NOOP = function() end
|
||||
|
||||
local function makeDispatch(opts)
|
||||
local storeChainBeforeProcess = opts.storeChainBeforeProcess
|
||||
local chains = {} -- {[inst] = {[k] = {[index] = retractor}}}
|
||||
local D = {}
|
||||
local handlers = {}
|
||||
|
||||
function D.addHandler(h)
|
||||
table.insert(handlers, h)
|
||||
table.sort(handlers, function(a, b)
|
||||
return a.priority > b.priority
|
||||
end)
|
||||
end
|
||||
|
||||
function D.getHandler(inst, k, v)
|
||||
for _, h in ipairs(handlers) do
|
||||
if h.isHandlable(inst, k, v) then
|
||||
return h
|
||||
end
|
||||
end
|
||||
error(string.format("Dispatch: 매치되는 핸들러 없음 (k=%s, v=%s)", tostring(k), tostring(v)))
|
||||
end
|
||||
|
||||
function D.process(inst, k, v, index)
|
||||
local byKey = chains[inst]
|
||||
if not byKey then
|
||||
byKey = {}
|
||||
chains[inst] = byKey
|
||||
end
|
||||
local list = byKey[k]
|
||||
if not list then
|
||||
list = {}
|
||||
if storeChainBeforeProcess then
|
||||
byKey[k] = list -- 올바른 순서: handler.process 호출 전에 등록
|
||||
end
|
||||
end
|
||||
if list[index] ~= nil then
|
||||
error(string.format("Dispatch: (inst,%s) 인덱스 %d는 이미 점유됨", tostring(k), index))
|
||||
end
|
||||
list[index] = NOOP -- 점유 마커(재귀 중 hole 방지 + 같은 index 재진입 가드)
|
||||
local h = D.getHandler(inst, k, v)
|
||||
list[index] = h.process(inst, k, v, index)
|
||||
if not storeChainBeforeProcess then
|
||||
byKey[k] = list -- 버그 순서: 재귀가 만든 별도 테이블을 덮어씀
|
||||
end
|
||||
end
|
||||
|
||||
function D.retractFrom(inst, k, index, v)
|
||||
local byKey = chains[inst]
|
||||
local list = byKey and byKey[k]
|
||||
if not list then
|
||||
return
|
||||
end
|
||||
for i = #list, index, -1 do
|
||||
local retractor = list[i]
|
||||
if retractor then
|
||||
retractor(if i == index then v else nil)
|
||||
end
|
||||
list[i] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function D.depth(inst, k)
|
||||
local byKey = chains[inst]
|
||||
local list = byKey and byKey[k]
|
||||
return if list then #list else 0
|
||||
end
|
||||
|
||||
return D
|
||||
end
|
||||
|
||||
--==========================================================================
|
||||
-- 핸들러 두 개 — StoreBind(재귀 위임) / Property(말단)
|
||||
--==========================================================================
|
||||
|
||||
local applied = {} -- inst.k -> 마지막으로 실제 세팅된 값
|
||||
local hintSeen = {} -- 각 핸들러가 자기 클로저에서 받은 hintValue 기록
|
||||
|
||||
local function makeHandlers(D)
|
||||
local StoreBind = {
|
||||
priority = 100,
|
||||
isHandlable = function(_, _, v)
|
||||
return isState(v)
|
||||
end,
|
||||
}
|
||||
|
||||
function StoreBind.process(inst, k, state, index)
|
||||
local unsubscribe = state:subscribe(function()
|
||||
local realv = state:Get()
|
||||
say(" [StoreBind idx=%d] %s 재발행 -> retractFrom(idx=%d) 후 재위임", index, state._name, index + 1)
|
||||
D.retractFrom(inst, k, index + 1, realv)
|
||||
D.process(inst, k, realv, index + 1)
|
||||
end)
|
||||
-- 등록 즉시 1회(실제 Observer 계약과 동일)
|
||||
local realv = state:Get()
|
||||
D.retractFrom(inst, k, index + 1, realv)
|
||||
D.process(inst, k, realv, index + 1)
|
||||
|
||||
return function(hintValue)
|
||||
table.insert(hintSeen, string.format("StoreBind(%s)@%d hint=%s", state._name, index, tostring(hintValue)))
|
||||
say(" [StoreBind idx=%d] retractor 호출 — %s 구독 해제 (hint=%s)", index, state._name, tostring(hintValue))
|
||||
unsubscribe()
|
||||
end
|
||||
end
|
||||
|
||||
local Property = {
|
||||
priority = 1,
|
||||
isHandlable = function()
|
||||
return true -- 최하위 폴백
|
||||
end,
|
||||
}
|
||||
|
||||
function Property.process(inst, k, v, index)
|
||||
applied[inst .. "." .. k] = v
|
||||
say(" [Property idx=%d] %s.%s = %s", index, inst, k, tostring(v))
|
||||
return function(hintValue)
|
||||
table.insert(hintSeen, string.format("Property@%d hint=%s", index, tostring(hintValue)))
|
||||
say(" [Property idx=%d] retractor 호출 (hint=%s)", index, tostring(hintValue))
|
||||
end
|
||||
end
|
||||
|
||||
D.addHandler(StoreBind)
|
||||
D.addHandler(Property)
|
||||
end
|
||||
|
||||
--==========================================================================
|
||||
-- 시나리오 실행기
|
||||
--==========================================================================
|
||||
|
||||
local function runScenario(label, storeChainBeforeProcess)
|
||||
say("")
|
||||
say("=====================================================")
|
||||
say("%s (chains 저장 순서: %s)", label, if storeChainBeforeProcess then "process 前(올바름)" else "process 後(버그 재현)")
|
||||
say("=====================================================")
|
||||
|
||||
applied, hintSeen = {}, {}
|
||||
local D = makeDispatch({ storeChainBeforeProcess = storeChainBeforeProcess })
|
||||
makeHandlers(D)
|
||||
|
||||
local inner = Store("v1", "inner")
|
||||
local outer = Store(inner, "outer")
|
||||
|
||||
say("")
|
||||
say("[1] 최초 마운트 — outer(State<State<string>>)를 인덱스 1로 진입")
|
||||
D.process("Frame", "Text", outer, 1)
|
||||
say(" -> 체인 깊이 = %d (기대: 3 — StoreBind/StoreBind/Property)", D.depth("Frame", "Text"))
|
||||
say(" -> Frame.Text = %s (기대: v1)", tostring(applied["Frame.Text"]))
|
||||
say(" -> outer 구독 %d개 / inner 구독 %d개 (기대: 1 / 1)", outer:subCount(), inner:subCount())
|
||||
|
||||
say("")
|
||||
say("[2] 안쪽 store 재발행 — index 3만 갈리고 1/2는 유지돼야 함")
|
||||
inner:Set("v2")
|
||||
say(" -> 체인 깊이 = %d (기대: 3)", D.depth("Frame", "Text"))
|
||||
say(" -> Frame.Text = %s (기대: v2)", tostring(applied["Frame.Text"]))
|
||||
say(" -> outer 구독 %d개 / inner 구독 %d개 (기대: 1 / 1 — 둘 다 살아있어야)", outer:subCount(), inner:subCount())
|
||||
|
||||
say("")
|
||||
say("[3] 바깥 store 재발행(새 inner2) — index 2/3이 깊은 쪽부터 정리돼야 함")
|
||||
local inner2 = Store("w1", "inner2")
|
||||
outer:Set(inner2)
|
||||
say(" -> 체인 깊이 = %d (기대: 3)", D.depth("Frame", "Text"))
|
||||
say(" -> Frame.Text = %s (기대: w1)", tostring(applied["Frame.Text"]))
|
||||
say(
|
||||
" -> outer %d / inner(옛) %d / inner2 %d (기대: 1 / 0 / 1 — 옛 inner 구독이 끊겨야)",
|
||||
outer:subCount(),
|
||||
inner:subCount(),
|
||||
inner2:subCount()
|
||||
)
|
||||
|
||||
say("")
|
||||
say("[4] 옛 inner를 다시 건드려도 아무 일도 없어야 함(구독이 끊겼으므로)")
|
||||
inner:Set("STALE")
|
||||
say(" -> Frame.Text = %s (기대: w1 — STALE로 안 바뀌어야)", tostring(applied["Frame.Text"]))
|
||||
|
||||
say("")
|
||||
say("[5] 전체 철거 — retractFrom(index=1)")
|
||||
D.retractFrom("Frame", "Text", 1, nil)
|
||||
say(" -> 체인 깊이 = %d (기대: 0)", D.depth("Frame", "Text"))
|
||||
say(" -> outer %d / inner2 %d (기대: 0 / 0)", outer:subCount(), inner2:subCount())
|
||||
|
||||
say("")
|
||||
say("[hint 전달 기록] retractFrom(idx,v)의 v가 target index에만 가는지:")
|
||||
for _, l in ipairs(hintSeen) do
|
||||
say(" %s", l)
|
||||
end
|
||||
end
|
||||
|
||||
runScenario("A~D. 정상 설계", true)
|
||||
runScenario("E. 음성 대조군 — chains 저장을 process 뒤로 옮기면", false)
|
||||
|
||||
say("")
|
||||
say("=====================================================")
|
||||
say("판정 가이드")
|
||||
say("=====================================================")
|
||||
say([[
|
||||
* 첫 번째(정상) 시나리오에서 [1]의 체인 깊이가 3이 아니면 — 인덱스
|
||||
누적 자체가 틀린 것. base 문서의 index+1 규칙부터 다시 볼 것.
|
||||
* [3]에서 "inner(옛) 구독 0"이 안 나오면 — 깊은 인덱스부터 정리하는
|
||||
retractFrom 순회가 깨진 것. [4]에서 STALE이 반영되는 것으로도 같은
|
||||
증상이 드러남.
|
||||
* 두 번째(음성 대조군) 시나리오에서 [1]의 체인 깊이가 **1**로 나오면 —
|
||||
2026-08-13 감사가 지적한 버그가 재현된 것: 재귀 위임이 자기만의
|
||||
list 테이블을 만들어 저장했다가 바깥 process가 그걸 덮어써서 index
|
||||
2/3의 retractor가 통째로 유실됨. 이어서 [3]에서 옛 inner 구독이
|
||||
안 끊기고 [4]에서 STALE이 반영되면 그 유실이 실제 동작 차이로
|
||||
이어진다는 확인.
|
||||
* 대조군이 정상 시나리오와 똑같이 나온다면(= 버그가 재현 안 되면)
|
||||
이 스파이크의 모델링이 실제 구현과 어긋난 것이니, 결론을 내리기
|
||||
전에 스파이크 쪽을 먼저 의심할 것.
|
||||
]])
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
--[[
|
||||
검증 대상: Dispatch가 (inst,k)별 핸들러 체인을 배열로 소유하고,
|
||||
retractUnder(inst,k,keep,v)가 꼬리부터 keep 앞까지 정리하는 설계
|
||||
(.claude/base/bind-system-plan.md "Dispatch 체인" 절)가 다단 체인
|
||||
(A->B->C)에서 실제로 정확한지 검증.
|
||||
|
||||
배경: 2026-08-08 세 번째 세션 — "전역 소유자 슬롯 하나"로 추적하는
|
||||
1차 설계가 재귀/래핑 핸들러(A가 B로 위임하는데 A 자신도 나중에
|
||||
재계산되는 경우)에서 깨지는 걸 반례로 확인하고 체인 방식으로 교체함.
|
||||
CLAUDE.md는 "M2/M4 스파이크 검증 목록에 chains/retractUnder가 다단
|
||||
체인에서 실제로 정확히 동작하는지가 새로 추가됨(추론만으로 확정된 것)"
|
||||
이라고 명시 — 아직 실제 Luau로 돌려본 적 없음. 이 파일이 그 검증.
|
||||
|
||||
시나리오: StoreA(바깥 store) -> StoreBind가 잡아서 그 값을 다시
|
||||
Dispatch.process로 재귀 -> 그 값이 또 다른 Store(StoreB, "이중 store"
|
||||
케이스를 흉내)일 때 두 번째 StoreBind가 또 잡아서 재귀 -> 최종적으로
|
||||
PropertyHandler가 실제 세팅. 즉 A(StoreBind)->B(StoreBind again)->C(Property)
|
||||
3단 체인. ("Store가 Store를 담지 않는다"가 설계상 확정이라 이 자체는
|
||||
UB에 가까운 입력이지만, 체인 메커니즘이 다단에서 실제로 버티는지는
|
||||
그것과 별개로 확인해둘 가치가 있어 일부러 스트레스 테스트로 씀.)
|
||||
|
||||
실행: `luau 04-dispatch-chain-retractUnder.luau`
|
||||
|
||||
참고(2026-08-09 세션 갱신 반영): 03번과 동일하게 아래 `subscribe(fn)`은
|
||||
이 스파이크 전용 단순화 — 실제로는 `state:Observer(fn)` +
|
||||
`bindLifetime`/`unbindLifetime` 조합(`.claude/base/lifecycle-pattern.md`)을
|
||||
쓴다. `retract`가 할 일이 "구독 해제"라는 본질은 같아서 체인/
|
||||
retractUnder 로직 검증엔 영향 없음.
|
||||
|
||||
**[2026-08-13 세션 정정]** 바로 위 문단의 마지막 문장은 3단계~4단계
|
||||
(store-in-store, 즉 `State<State<T>>`에 대응하는 시나리오)에 대해서는
|
||||
틀렸음이 드러남 — 이 스파이크의 `retract`는 print만 하는 완전 no-op이라
|
||||
"자기 자신을 스스로 retract하는" 버그가 실제로 일어나도 아무 부작용
|
||||
없이 넘어가버려서, 겉보기엔(체인 길이만 보면) 3~4단계가 정상으로
|
||||
보임. 실제로는 같은 싱글톤 `StoreBindA` 핸들러가 같은 `(inst,k)`에
|
||||
두 번 push되고(`list={StoreBindA,StoreBindA}`), `retractUnder`의
|
||||
457행 "첫 매치 cutoff" 로직이 안쪽 재계산 시점에도 항상 **바깥쪽**
|
||||
인덱스를 cutoff로 잡아버려 안쪽 자기 자신이 잘못 retract되는 게
|
||||
`.claude/base/bind-system-plan.md` "확정된 디스패치 모델" 절
|
||||
2026-08-13 항목에서 손으로 트레이싱해 확인됨 — 실제 `bindLifetime`
|
||||
기반 구현이었다면 안쪽 State 구독이 등록 직후 끊겨 이후 갱신이
|
||||
조용히 무시됐을 것. 이 파일이 "다단 체인이 정상"이라고 결론 내린 건
|
||||
이 스텁의 사각지대 때문이었지 실제로 검증된 게 아니었음 — 그래서
|
||||
아래에 같은 `(inst,k)`에 같은 핸들러가 중복 push되면 즉시 error하는
|
||||
가드(같은 세션에 base 문서 pseudocode에도 추가됨)를 넣고, 3단계에서
|
||||
이 가드가 실제로 걸리는지 확인하는 걸로 대체.
|
||||
]]
|
||||
|
||||
local StoreTag = {}
|
||||
local function isStoreLike(v)
|
||||
return type(v) == "table" and v[StoreTag] == true
|
||||
end
|
||||
local function makeStore(initial)
|
||||
local self = { [StoreTag] = true, value = initial, subscribers = {} }
|
||||
function self.get(_self)
|
||||
return self.value
|
||||
end
|
||||
function self.set(_self, v)
|
||||
self.value = v
|
||||
for _, fn in self.subscribers do
|
||||
fn(v)
|
||||
end
|
||||
end
|
||||
function self.subscribe(_self, fn)
|
||||
table.insert(self.subscribers, fn)
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
-- Relate 대용 (weak-key까지는 이 스파이크에서 안 다룸, 순수 로직 검증이 목적 —
|
||||
-- weak-key/GC 쪽은 07-relate-weak-table-gc.luau가 따로 다룸)
|
||||
local chains = {} -- [inst] = { [k] = { handler, handler, ... } }
|
||||
local function chainFor(inst, k)
|
||||
chains[inst] = chains[inst] or {}
|
||||
chains[inst][k] = chains[inst][k] or {}
|
||||
return chains[inst][k]
|
||||
end
|
||||
|
||||
local Dispatch = {}
|
||||
local handlers = {}
|
||||
|
||||
function Dispatch.addHandler(h)
|
||||
table.insert(handlers, h)
|
||||
table.sort(handlers, function(a, b)
|
||||
return a.priority > b.priority
|
||||
end)
|
||||
end
|
||||
|
||||
function Dispatch.getHandler(inst, k, v)
|
||||
for _, h in handlers do
|
||||
if h.isHandlable(inst, k, v) then
|
||||
return h
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Dispatch.process(inst, k, v)
|
||||
local h = Dispatch.getHandler(inst, k, v)
|
||||
if not h then
|
||||
print(string.format(" (매치 없음: %s.%s = %s)", tostring(inst), tostring(k), tostring(v)))
|
||||
return
|
||||
end
|
||||
local list = chainFor(inst, k)
|
||||
-- [2026-08-13 세션 신설] 같은 (inst,k)에 같은 핸들러 객체가 이미 체인에
|
||||
-- 있으면 error — State<State<T>>류 재진입 디스패치를 조용히 깨지게
|
||||
-- 두지 않고 즉시 실패시킴 (base/bind-system-plan.md 동시 반영)
|
||||
for _, existing in list do
|
||||
if existing == h then
|
||||
error(
|
||||
string.format(
|
||||
"Dispatch: handler '%s' already active for %s.%s — re-entrant dispatch (e.g. State<State<T>>) is not supported",
|
||||
h.name,
|
||||
tostring(inst),
|
||||
tostring(k)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
table.insert(list, h)
|
||||
print(string.format(" [chain push] %s.%s <- %s (체인 길이=%d)", tostring(inst), tostring(k), h.name, #list))
|
||||
h.process(inst, k, v)
|
||||
end
|
||||
|
||||
-- .claude/base/bind-system-plan.md의 pseudo code 그대로 옮김
|
||||
function Dispatch.retractUnder(inst, k, keep, v)
|
||||
local list = chainFor(inst, k)
|
||||
local cutoff = 0
|
||||
if keep then
|
||||
for i, h in list do
|
||||
if h == keep then
|
||||
cutoff = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
for i = #list, cutoff + 1, -1 do
|
||||
local retractedHandler = list[i]
|
||||
local passedValue = (i == cutoff + 1) and v or nil
|
||||
print(
|
||||
string.format(
|
||||
" [retractUnder] %s.%s: %s.retract(v=%s) 호출, 체인에서 제거",
|
||||
tostring(inst),
|
||||
tostring(k),
|
||||
retractedHandler.name,
|
||||
tostring(passedValue)
|
||||
)
|
||||
)
|
||||
retractedHandler.retract(inst, k, passedValue)
|
||||
list[i] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- 핸들러: StoreBind (self 식별을 위해 핸들러 테이블 자기 자신을 process 안에서 캡처)
|
||||
local function makeStoreBindHandler(name, priority)
|
||||
local self
|
||||
self = {
|
||||
name = name,
|
||||
priority = priority,
|
||||
isHandlable = function(inst, k, v)
|
||||
return isStoreLike(v)
|
||||
end,
|
||||
process = function(inst, k, store)
|
||||
local function reprocess(realv)
|
||||
print(
|
||||
string.format(
|
||||
" [%s] %s.%s 재계산 -> retractUnder(keep=self) 먼저, 그 다음 재귀 process",
|
||||
name,
|
||||
tostring(inst),
|
||||
tostring(k)
|
||||
)
|
||||
)
|
||||
Dispatch.retractUnder(inst, k, self, realv)
|
||||
Dispatch.process(inst, k, realv)
|
||||
end
|
||||
store:subscribe(reprocess)
|
||||
reprocess(store:get())
|
||||
end,
|
||||
retract = function(inst, k, v)
|
||||
print(string.format(" [%s.retract] 나 자신(구독) 정리", name))
|
||||
end,
|
||||
}
|
||||
return self
|
||||
end
|
||||
|
||||
Dispatch.addHandler(makeStoreBindHandler("StoreBindA", 900))
|
||||
Dispatch.addHandler({
|
||||
name = "PropertyHandler",
|
||||
priority = 0,
|
||||
isHandlable = function()
|
||||
return true
|
||||
end,
|
||||
process = function(inst, k, v)
|
||||
print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v)))
|
||||
end,
|
||||
retract = function(inst, k, v)
|
||||
print(string.format(" [PropertyHandler.retract] 이전 값 무름"))
|
||||
end,
|
||||
})
|
||||
|
||||
print('=== 1단계: StoreA(값="hello") 바인드 ===')
|
||||
local storeA = makeStore("hello")
|
||||
Dispatch.process("Frame1", "Text", storeA)
|
||||
print(" 현재 체인 길이:", #chainFor("Frame1", "Text"))
|
||||
|
||||
print()
|
||||
print("=== 2단계: StoreA 값을 다른 일반 값으로 바꿈(체인이 A 밑을 정확히 정리하는가) ===")
|
||||
storeA:set("world")
|
||||
print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개여야 정상)")
|
||||
|
||||
print()
|
||||
print("=== 3단계 [2026-08-13 세션 재작성]: StoreA 값을 store로 다시 바꿔서")
|
||||
print(" (State<State<T>>에 대응) 재진입 디스패치를 유도 — 이제 가드가")
|
||||
print(" 즉시 error해야 정상 ===")
|
||||
local storeB = makeStore("nested")
|
||||
local ok, err = pcall(function()
|
||||
storeA:set(storeB)
|
||||
end)
|
||||
print(" error로 막혔는가:", not ok)
|
||||
print(" 에러 메시지:", tostring(err))
|
||||
print(
|
||||
" 현재 체인 길이:",
|
||||
#chainFor("Frame1", "Text"),
|
||||
"(가드가 push 전에 error하므로 1이어야 정상 — StoreBindA만 남고 오염 없음)"
|
||||
)
|
||||
|
||||
print()
|
||||
print("=== 4단계: 가드가 걸린 뒤에도 같은 자리에 정상 값으로 다시 바인드하면")
|
||||
print(" 문제없이 복구되는가(에러가 체인을 영구 오염시키지 않는가) ===")
|
||||
storeA:set(42)
|
||||
print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개로 정상 복구되어야 함)")
|
||||
|
||||
--[[
|
||||
확인 포인트 (이게 이 파일의 핵심 목적):
|
||||
1. 2단계에서 storeA:set("world") 이후 체인 길이가 정확히 2(A, Property)로
|
||||
돌아오는가 — retractUnder가 이전 PropertyHandler를 정리하고 새로
|
||||
push했는가, 아니면 계속 누적돼서 체인이 무한정 길어지는가?
|
||||
(누적되면 버그 — 체인이 GC 안 되는 메모리 누수이자 논리 오류)
|
||||
2. **[2026-08-13 세션 정정]** 3단계는 원래 "A(StoreBindA) 자신은 살아남고
|
||||
그 밑만 정리되는가"를 확인하려 했으나, 이 스파이크의 `retract`가
|
||||
print만 하는 no-op이라 실제 자기-retract 버그(같은 싱글톤 핸들러가
|
||||
같은 (inst,k)에 중복 push되고, retractUnder의 첫-매치 cutoff가 안쪽
|
||||
재계산 시점에 바깥쪽 인덱스를 잡아 안쪽이 스스로를 retract하는 것 —
|
||||
상세 트레이싱은 `.claude/base/bind-system-plan.md` "확정된 디스패치
|
||||
모델" 절 2026-08-13 항목)를 이 스텁으로는 절대 못 잡는다는 게 드러남.
|
||||
그래서 이제 3단계는 "같은 핸들러 중복 push를 Dispatch.process가
|
||||
push 전에 잡아 즉시 error하는가"를 확인 — `ok`가 `false`고 체인
|
||||
길이가 오염 없이 1로 남는 게 정상.
|
||||
3. 4단계: 가드가 발동한 뒤에도 같은 (inst,k)에 정상적으로 새 값을
|
||||
바인드할 수 있는가(에러가 chains 자료구조를 영구히 깨뜨리지
|
||||
않는가) — 체인 길이가 다시 2(A, Property)로 정상 복구돼야 함.
|
||||
4. 스택 오버플로 없이 전부 정상 종료되는가.
|
||||
]]
|
||||
|
|
@ -1,4 +1,28 @@
|
|||
--[[
|
||||
*** [2026-08-13 감사] B/C 섹션은 폐기·변경된 설계를 검증 중 — 돌리기 전에
|
||||
*** 아래대로 먼저 고칠 것. A 섹션은 그대로 유효(단 `kTagMap`은 이제 없음,
|
||||
*** 클로저가 `v`를 직접 캡처하므로 `tagNameMap` 하나만 남음 — 참조 카운트
|
||||
*** 로직 자체는 안 바뀌어서 A의 검증 내용은 그대로 성립).
|
||||
***
|
||||
*** B) `rawNew`+`owners` 수동 레지스트리는 2026-08-13 하루 안에 두 번
|
||||
*** 뒤집혀 완전히 사라짐. 지금 설계는 그룹이 **공개 `AttributeKey(name)`**
|
||||
*** 으로 항상 인덱스 1에 `Dispatch.process`만 부르고(retractFrom은
|
||||
*** 부르지 않음 — 부르면 점유 체크가 무력화됨), 철거는 반환 클로저가
|
||||
*** 자기가 등록한 이름 전부에 대해 수행. 소유권 충돌은 별도 레지스트리가
|
||||
*** 아니라 **`Dispatch.process`의 인덱스 점유 체크**가 잡음.
|
||||
*** → B는 "그룹 A가 점유한 이름을 그룹 B가 잡으면 error가 나는가"를
|
||||
*** 인덱스 점유 모델로 다시 써야 함(`base/attribute-plan.md`
|
||||
*** "이름 소유권"/"메커니즘" 절).
|
||||
***
|
||||
*** C) `claimOwner`가 "같은 owner면 no-op(false)"이던 3분기가 감사에서
|
||||
*** 버그로 판정돼 둘로 쪼개짐 — nested(`rawAdd`)는 **엄격**(같은 owner
|
||||
*** 재클레임도 error, `Slot{a,a}`를 막기 위함), top-level만
|
||||
*** `claimOwnerAt(element, inst, k)`으로 **위치까지** 봐서 spurious
|
||||
*** 재발행만 false. `destroySlotTree`/`rawRemove`의 `releaseOwner`
|
||||
*** 호출도 새로 추가됨.
|
||||
*** → C는 이 두 함수를 각각 검증하도록 다시 써야 함
|
||||
*** (`base/slot-plan.md` "요소 소유권 — `elementOwner`" 절).
|
||||
|
||||
검증 대상: "retract는 항상 불림" 전면 정정(2026-08-12 열한 번째 세션) 이후
|
||||
새로 생긴 세 가지 소유권/참조카운트 추적 로직이 실제로 짜인 대로 동작하는지 —
|
||||
전부 여러 위치/여러 사이클에 걸쳐 상태가 정확히 갱신되는지가 핵심이라
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 |
|
||||
| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 |
|
||||
| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 |
|
||||
| `04-dispatch-chain-retractUnder.luau` | `Dispatch` 체인 + `retractUnder`가 다단(A→B→C) 재-dispatch에서 정확한지, **[2026-08-13 재작성]** 같은 `(inst,k)`에 같은 핸들러가 중복 push되는 경우(`State<State<T>>`류) `Dispatch.process`의 신규 가드가 즉시 error하고 이후 정상 복구되는지 | `bind-system-plan.md` "Dispatch 체인" + "확정된 디스패치 모델" 2026-08-13 항목, 2026-08-08 세 번째 세션 / 2026-08-13 세션 |
|
||||
| `04-dispatch-chain-retractFrom.luau` | **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State<State<T>>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `bind-system-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 |
|
||||
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 |
|
||||
| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 |
|
||||
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" |
|
||||
|
|
@ -64,7 +64,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `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번과는 다른 새 알고리즘 모양이라 별도 실측 필요하다고 판단) |
|
||||
| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규]** "retract는 항상 불림" 정정(2026-08-12 열한 번째 세션) 이후 새로 생긴 세 소유권/참조카운트 알고리즘(A: Tag `kTagMap`/`tagNameMap` 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`, B: ~~Attribute `rawNew`+`owners` 소유권~~ — **[2026-08-13 감사] 이 섹션은 폐기된 설계를 검증 중이라 재작성 필요**: `rawNew`/`owners` 수동 레지스트리는 두 번 뒤집혀 사라졌고, 지금은 그룹이 공개 `AttributeKey(name)`으로 항상 인덱스 1에 위임하고 `Dispatch.process`의 **점유 체크**가 소유권 충돌을 대신 잡음 — 검증 대상도 그쪽으로 바뀌어야 함, C: Slot `elementOwner`의 `claimOwner`/`releaseOwner` — **[2026-08-13 감사] 이 섹션도 갱신 필요**: nested는 엄격 `claimOwner`(같은 owner 재클레임도 error), top-level만 `claimOwnerAt`으로 `(inst,k)`까지 봐서 spurious 재발행을 구분)가 실제로 정확히 갈리는지 | `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 여섯 번째 세션) |
|
||||
|
||||
## 공통 유틸리티
|
||||
|
|
|
|||
|
|
@ -10,6 +10,132 @@
|
|||
|
||||
## 지금 열려있는 것 (우선순위순)
|
||||
|
||||
### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관)
|
||||
|
||||
**이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의
|
||||
재디스패치 모델은 나머지가 전부 확정됐고, 이 항목 하나만 정해지면
|
||||
`bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/`attribute-plan.md`를
|
||||
한 번에 옮기면 됨.
|
||||
|
||||
**문제**: 새 모델(핸들러 선비교)에서는 그룹 A가 잡아둔
|
||||
`AttributeKey("foo")` 인덱스 1에 그룹 B가 들어와도 **양쪽 다 `StoreBind`에
|
||||
매치되므로 "같은 핸들러"로 판정돼 조용히 갈아탐.** 그리고 나중에 A의
|
||||
클로저가 자기 이름들을 `retractFrom`할 때 B의 바인딩을 대신 철거함
|
||||
(교차 오염). 예전 "조용한 last-write-wins"가 그대로 돌아옴 — 이번 감사에서
|
||||
고쳤던 바로 그 증상. 즉 **Dispatch의 점유 체크가 대신 잡아주던 걸 이제
|
||||
Attribute가 직접 해야 함.**
|
||||
|
||||
**사용자 방향(2026-08-13, 심층 분석은 다음 세션)**:
|
||||
> "Attribute 소유권은 아마 이전 결정을 다시 가져오는게 맞아보이긴 하네요.
|
||||
> 막 깊게 Key -> Group 필요한것 같지는 않고, 본인 retract 처리를 수행할 때
|
||||
> 무언가 하면 될듯 한데. 이 부분은 나중에 제가 물리적으로 스케치 해보며
|
||||
> 심층 분석해보겠습니다."
|
||||
|
||||
- **"이전 결정을 다시 가져온다"** = 2026-08-13 네 번째 세션의 이름별
|
||||
claimant `Relate`(당시 이름 `owners`). **당시 기각 사유는 새 모델에서
|
||||
구조적으로 소멸함** — 그때 버그는 "소유권 반납이 `process`의 `v==nil`
|
||||
분기에만 있어서, 그룹이 이름을 통째로 놓는 경로가 그 분기를 안 타
|
||||
옛 소유권이 안 지워짐"이었는데, 지금은 **클로저가 항상 불리므로 거기서
|
||||
반납**하면 그 구멍이 안 생김. 사용자의 "본인 retract 처리를 수행할 때
|
||||
무언가 하면 될듯"이 정확히 이 지점.
|
||||
- **"막 깊게 Key → Group 필요한 것 같지는 않다"** — 키에서 그룹으로
|
||||
거슬러 올라가는 양방향 레지스트리까지는 필요 없고, 이름 → 현재
|
||||
claimant 단방향이면 충분할 것이라는 방향.
|
||||
- 원문 맥락과 기각된 두 중간안(`rawNew` 전용 키, `AttributeGroupKeyHandler`
|
||||
체크포인트)은 `archive/checkpoint-handler-pattern-reversed.md`,
|
||||
분석은 `research/dispatch-redispatch-diff-plan.md` 5절.
|
||||
|
||||
**대안 후보(정리해둠)**: (a) 이름별 claimant `Relate`를 Attribute에
|
||||
국소적으로 — 권고, (b) UB로 두고 문서로만 금지 — 증상이 "조용한 오작동 +
|
||||
교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 나빠서 비권장,
|
||||
(c) `Dispatch`에 claimant 개념 일반화 — 이번에 걷어낸 방향이라 반대.
|
||||
|
||||
### 0-A. `hintValue` 폐기 → process 하강 중 핸들러 비교 (2026-08-13 여섯 번째 세션, **Attribute 건 외 확정**)
|
||||
|
||||
**검토 결과 사용자 지적이 맞음 — 현행 `hintValue`엔 실제 결함이 있음.**
|
||||
힌트가 "그 자리에 곧 디스패치될 raw 값"이라 `None` 센티널이나 `State`/
|
||||
`Tween` 같은 래퍼가 그대로 넘어갈 수 있고, 그러면 말단 핸들러의
|
||||
`isTag(hint)` 가드가 거짓이 되어 **깜빡임/재생성 방지가 조용히 꺼짐**
|
||||
(정확성은 유지돼서 지금까지 안 드러났음). 상세 재현·분석·제안은
|
||||
`research/dispatch-redispatch-diff-plan.md`.
|
||||
|
||||
**후속 라운드에서 모델은 거의 확정됨** — 래핑 핸들러가 `retractFrom`을
|
||||
선행 호출하는 걸 폐기하고, `Dispatch.process` 안에서 **핸들러를 먼저
|
||||
비교**해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` 재호출,
|
||||
다르면 그 자리부터 아래를 전량 철거). 이걸로 (a) 힌트의 타입이
|
||||
구조적으로 보장되고(같은 핸들러일 때만 값이 넘어가므로), (b) 깊은 체인의
|
||||
힌트 유실도 사라지며(각 레벨이 자기 재프로세스에서 자기 힌트를 받음),
|
||||
(c) `oldValue`를 따로 넘기자던 보완안은 불필요해짐(사용자 지적:
|
||||
"클로저라 이미 본인이 알지 않아요?" — 맞음, `chains`에 추가로 저장할 건
|
||||
비교용 `handler` 하나뿐), (d) `HandlerChanged` 마커도 불필요(핸들러가
|
||||
바뀌었다는 건 retractor가 `nil` 힌트로 불린다는 사실로 이미 표현됨).
|
||||
|
||||
**남은 열린 항목은 Attribute 이름 소유권 하나뿐 — 위 0-Z로 분리해
|
||||
최우선 배치**(사용자가 다음 세션에 직접 스케치하며 심층 분석하기로).
|
||||
그 하나 외에는 이 항목에 결정할 게 없음.
|
||||
|
||||
**실행 규모**: `base/`의 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/
|
||||
`attribute-plan.md` 의사코드 재작성 — 0-Z 하나만 정해지면 한 번에 옮기면
|
||||
됨(어디를 어떻게 고칠지는 `research/dispatch-redispatch-diff-plan.md` 6절에
|
||||
파일별로 적어둠). **그때까지 `base/`의 현행 `hintValue` 서술이 유효** —
|
||||
아직 안 옮겼다는 걸 잊고 base만 읽으면 옛 모델로 구현하게 되니 주의.
|
||||
|
||||
### 0-B. `dispose(any)` — 시그니처/범위 (2026-08-13 여섯 번째 세션 신설, 사용자 제안)
|
||||
|
||||
`State<Slot>` 교체를 파괴가 아니라 **언마운트**로 확정하면서(`state<Frame>`와
|
||||
동일, `base/slot-plan.md`), 명시적 파괴 수단으로 base 탑레벨 `dispose(value)`를
|
||||
제공하기로 방향 확정. "이 값이 지금 어디 마운트돼 있는가"는 이미
|
||||
`elementOwner`가 들고 있어(다중 마운트 error 판정용) 새 부기가 필요 없음.
|
||||
|
||||
**[확정, 사용자] 시맨틱은 "거부"** — 대상이 아직 어느 트리에 의해
|
||||
살아있길 요구되고 있으면 **파괴를 거부하고 즉시 error**. 떼어내주지
|
||||
않음(떼어내는 건 `Set`=언마운트의 몫, `dispose`는 그 뒤). 근거: 엔진은
|
||||
`Destroy`/`Clear`에 에러를 안 내지만 quad의 `_elements`/`lengthList`/
|
||||
`sourceList`/`elementOwner`는 그 순간 어긋나므로, **quad가 관리 중인 값을
|
||||
안전하게 지우는 유일한 경로**가 이것이고 "지금 지우면 안 되는 상태"를
|
||||
잡아주는 게 존재 이유. 이걸로 "`Set` 전에 직접 `Destroy()`"가 UB에서
|
||||
명확한 에러로 바뀜.
|
||||
|
||||
**미확정**: 시그니처(`dispose(any)`가 맞는지, 타입을 어떻게 좁힐지),
|
||||
대상 범위(Slot 외에 Instance/Observer/Effect까지 커버하는지),
|
||||
`unbindLifetime`과의 역할 분담.
|
||||
|
||||
### 0-C. 포탈 — `Extract` 비파괴 경로로 해결되는가 (2026-08-13 여섯 번째 세션 신설, 사용자 제안 — **해결됨**)
|
||||
|
||||
**질문(사용자)**: `stateSlot:Get()`으로 Slot을 뽑아두고 → `Set()`으로 다른
|
||||
Slot을 넣고 → 뽑아둔 Slot을 다른 곳에 넣는 게 되는가. 되면 "포탈"이
|
||||
이걸로 해결됨.
|
||||
|
||||
**조사 결과**: 지금은 **안 됨** — `:List`의 `reconcile`이 교체 시
|
||||
`rawRemove`(제거 **+ 파괴**)를 부르므로 뽑아둔 레퍼런스가 이미 파괴된
|
||||
Slot이 됨. 막는 게 소유권 규칙이 아니라 "제거 = 파괴"라는 reconcile의
|
||||
선택 하나뿐이라는 게 핵심.
|
||||
|
||||
**그런데 나머지 부품은 이미 다 있음**: `Extract`/`ExtractAll`/`Splice`가
|
||||
이미 비파괴로 확정돼 있고, `claimOwner`/`releaseOwner`가 소유권을 정확히
|
||||
이양하며, `attachSlot`은 재마운트를 구조적으로 이미 지원함(자식이 파괴만
|
||||
안 됐다면 새 물리 부모로 그대로 flush). 즉 **"포탈은 별도 메커니즘이
|
||||
필요하다"는 기존 전제가 틀렸을 가능성이 큼.**
|
||||
|
||||
**[해결, 사용자 결정]** opt-in이 아니라 **기본 동작**으로 확정 —
|
||||
`State<Slot>` 교체는 원래부터 `state<Frame>`와 같이 언마운트여야 했다는
|
||||
판단이라, 포탈은 별도 기능이 아니라 그 결정의 귀결. 안 지운 Slot은
|
||||
아무도 안 들고 있으면 GC되고, 지금 죽이려면 `dispose`(위 0-B).
|
||||
**[해소, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음** — 옛 owner에
|
||||
대해 그냥 `setLength(ownerKey, position, 0)` + `setOffsetSource(ownerKey,
|
||||
position, None)`을 다시 부르면 됨(이미 확정된 "마운트 안 하는 위치는
|
||||
`0`/`None` 등록" 관용구 그대로). 즉 **해제 = 0/`None`으로 재등록**.
|
||||
`state<state<Frame>>`류로 offset이 밀리는 문제는 `state<state<Tag>>`와
|
||||
같은 범주로 **"그냥 확인된 것"으로 수용**(평탄화 도구가 처리, 케이스 드묾).
|
||||
상세는 `base/slot-plan.md` "`State<Slot>` 교체는 파괴가 아니라 언마운트" 절.
|
||||
|
||||
**관련(같은 절, 위 결정으로 함께 해소)**: `State<Slot?>`를
|
||||
`nil`↔`slotA`로 왕복시키는 코드가 두 번째 등장부터 깨진 서브트리를 내던
|
||||
문제도 언마운트 전환으로 사라짐. 대신 **`Set`으로 덮어쓰기 *전에*
|
||||
이전 값을 직접 `Destroy()`하는 건 UB**(`state<Frame>`에서 먼저
|
||||
`frame:Destroy()`하고 `Set`하는 것과 같은 문제) — 순서는 항상
|
||||
`Set`(언마운트) → 그 다음 정리.
|
||||
|
||||
### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06~07)
|
||||
|
||||
사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것
|
||||
|
|
@ -199,7 +325,8 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
|
|||
`LifetimeHandle`/`Relate` 인터페이스(타입만)를 `ROADMAP.md`
|
||||
M2로 옮기고, quad-roblox 실 구현만 M8에 남김 — 우선순위1-9 해소.
|
||||
- **[해소됨]** retract 시 "이전 핸들러" 추적 책임 소재 — Dispatch 체인
|
||||
(`chains`)+`Dispatch.retractUnder`로 2026-08-08 세 번째 세션에 이미
|
||||
(`chains`)+`Dispatch.retractFrom`(2026-08-08 세 번째 세션엔 `retractUnder`라는
|
||||
이름이었음, 2026-08-13 다섯 번째 세션에 인덱스 기반으로 재설계되며 개명)로 이미
|
||||
해소(`pre-implementation-audit.md` 1-2, `bind-system-plan.md` "Dispatch
|
||||
체인" 절). **[해소됨, 2026-08-09 세션]** `:Compute`의 `previous` 인자
|
||||
오버엔지니어링 의심도 기각(`bind-system-plan.md` "previous" 절,
|
||||
|
|
|
|||
|
|
@ -140,12 +140,13 @@ instance로 직접 트윈되어버리는지" 같은 걸 구분하고 싶음. 이
|
|||
Instance에 직접 `TweenService:Create()`를 건 것을 구분하려면 **핸들러 자신만
|
||||
아는 맥락**이 필요.
|
||||
|
||||
**제안**: `isHandlable`/`priority`/`process`/`retract` 4종 계약에 선택적
|
||||
5번째 훅을 추가 — `describe(inst, k, v): DebugInfo?`(가칭, 기본 미구현
|
||||
**제안**: `isHandlable`/`priority`/`process` 3종 계약(2026-08-13 다섯 번째
|
||||
세션에 `retract`가 `process` 반환값으로 합쳐지기 전엔 4종)에 선택적
|
||||
훅 하나를 추가 — `describe(inst, k, v): DebugInfo?`(가칭, 기본 미구현
|
||||
= no-op과 동일 효과). quad-debug가 트레이스 이벤트를 기록할 때 해당 키를
|
||||
처리한 핸들러에게 `describe`가 있으면 호출해서 사람이 읽을 수 있는 부가
|
||||
정보(예: Tween 핸들러라면 "store-bind 유발" vs "직접 세팅"인지, 어떤 store
|
||||
key에서 왔는지)를 이벤트에 덧붙임. `bind-system-plan.md`가 이미 "4종 계약은
|
||||
key에서 왔는지)를 이벤트에 덧붙임. `bind-system-plan.md`가 이미 "계약은
|
||||
지금 확정이지만 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적
|
||||
확장)"라고 열어둔 것과 정확히 맞아떨어지는 케이스 — 새 원칙이 아니라 이미
|
||||
예견된 확장.
|
||||
|
|
|
|||
240
.claude/research/dispatch-redispatch-diff-plan.md
Normal file
240
.claude/research/dispatch-redispatch-diff-plan.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# 재디스패치 = 하강 diff — `retractFrom` 선행 폐기, 핸들러 비교를 클로저 호출 앞에 (설계안)
|
||||
|
||||
**상태**: research — 2026-08-13 여섯 번째 세션에 사용자가 제기하고 방향을
|
||||
제시, 같은 세션 후속 라운드에서 모델이 거의 확정됨. **`base/` 반영 전
|
||||
남은 열린 항목은 아래 5절의 하나뿐**(Attribute 이름 소유권). 그 하나만
|
||||
정해지면 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/
|
||||
`attribute-plan.md`를 한 번에 옮기면 됨.
|
||||
|
||||
> **문서 이력**: 최초엔 `dispatch-hint-to-oldvalue-plan.md`라는 이름으로
|
||||
> "힌트 대신 `oldValue`를 넘기자"는 보완안을 담고 있었으나, 사용자가
|
||||
> **"이전 값인 oldValue는 처음부터 클로저라 이미 본인이 알지 않아요?"**
|
||||
> 라고 지적해 그 보완안이 통째로 불필요함이 드러남(아래 3-2) — 파일명도
|
||||
> 바꿈.
|
||||
|
||||
## 1. 현행 `hintValue`의 실제 결함 (사용자 지적, 확인됨)
|
||||
|
||||
현행 계약: `Dispatch.retractFrom(inst,k,index,v)`가 `index` 자리 retractor에
|
||||
`v`를 넘김. 그 `v`는 **"그 자리에 곧 디스패치될 raw 값"**이다.
|
||||
|
||||
**문제: 그 raw 값이 핸들러가 이해하는 의미 값이라는 보장이 전혀 없음.**
|
||||
|
||||
### 1-1. 힌트로 `None` 센티널이 그대로 넘어감 (사용자 제기, 재현됨)
|
||||
|
||||
`[AttributeKey "foo"] = state`, state가 `5` ↔ `None`을 오갈 때:
|
||||
|
||||
| 사이클 | 체인 |
|
||||
|---|---|
|
||||
| `None` | `StoreBind@1` → `NoneHandler@2` → `AttributeKeyHandler@3` |
|
||||
| `5` | `StoreBind@1` → `AttributeKeyHandler@2` |
|
||||
|
||||
`5` → `None`으로 갈 때 StoreBind는 `retractFrom(inst,k,2,None)`을 부르고,
|
||||
인덱스 2의 `AttributeKeyHandler` retractor가 **`hintValue = None`** 을 받음.
|
||||
Attribute는 클로저가 no-op이라 무해하지만, `TagHandler`였다면
|
||||
`isTag(None)`이 거짓이라 "이 자리가 Tag이길 그만둔다"로 오판해 이름 전부를
|
||||
`RemoveTag`함.
|
||||
|
||||
### 1-2. 래핑이 한 겹만 끼어도 힌트가 무의미해짐
|
||||
|
||||
같은 이유로 힌트가 `State`, `Tween`, 그 외 미래에 추가될 어떤 래퍼여도
|
||||
말단 핸들러는 그걸 해석 못 함. 앞서 문서화했던 "깊이 2 이상에선 `nil`"
|
||||
문제는 이 결함의 **한 특수 케이스**였을 뿐 — 진짜 문제는 깊이가 아니라
|
||||
**"힌트의 타입이 계약으로 정해져 있지 않다"**는 것.
|
||||
|
||||
### 1-3. 그래서 힌트 기반 최적화가 "가끔 조용히 꺼진다"
|
||||
|
||||
`Tag`의 `Contains` skip, `Ref`/`Slot`의 identity 비교는 전부 힌트가
|
||||
자기 타입일 때만 동작 — 위 경로들에선 소리 없이 전량 정리로 퇴화함.
|
||||
**정확성은 유지되지만(그래서 지금까지 안 드러남) 깜빡임/재생성 방지라는
|
||||
존재 이유가 무너짐.** 특히 `Slot`은 "가드가 없으면 마운트된 서브트리
|
||||
전체가 파괴됐다 재생성"이라 파급이 큼.
|
||||
|
||||
## 2. 채택 모델 — 재디스패치는 "철거 후 재구축"이 아니라 "하강 diff"
|
||||
|
||||
사용자 정리:
|
||||
|
||||
> "차라리 process 를 쭉 진행해, 이전거랑 자신 것이랑 다르면 nil 또는,
|
||||
> 그걸 나타내는 HandlerChanged 등의 무언가로 아래를 전부 죽이고, 같으면
|
||||
> 값을 계속 넣어가며 전파해야할듯, 같은것이면 본인 인덱스에 대해서만
|
||||
> 후처리를 해."
|
||||
>
|
||||
> "retract 계약이 지금 보면, 일단 시도해보는데, 이전이 내 핸들러면 내가
|
||||
> 처리, 아니고 다르면 아래쪽을 그냥 retract 처리해서 전부 제거"
|
||||
|
||||
즉 **래핑 핸들러가 재-dispatch 전에 `retractFrom`을 먼저 때리는 것을
|
||||
폐기**하고, 그냥 `Dispatch.process`를 아래로 내려보냄. 비교는
|
||||
`Dispatch.process` 안에서 일어남:
|
||||
|
||||
```lua
|
||||
-- chains[inst][k][index] = { handler = h, retractor = fn }
|
||||
function Dispatch.process(inst, k, v, index)
|
||||
local list = <확보 + chains에 등록> -- 기존 순서 규칙 그대로(h.process 前)
|
||||
local slot = list[index]
|
||||
local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존대로 즉시 error
|
||||
|
||||
if slot ~= nil and slot.handler == h then
|
||||
-- 같은 핸들러: 아래를 안 건드리고, 이 자리 클로저에 새 값을 넘겨
|
||||
-- 스스로 전이를 처리하게 함. v는 h.isHandlable(v)가 참임이 보장됨.
|
||||
slot.retractor(v)
|
||||
slot.retractor = h.process(inst, k, v, index)
|
||||
else
|
||||
-- 다른 핸들러(또는 빈 자리): 이 자리부터 아래를 전부 철거 후 새로 설치
|
||||
Dispatch.retractFrom(inst, k, index, nil)
|
||||
list[index] = { handler = h, retractor = h.process(inst, k, v, index) }
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`StoreBind`/`NoneHandler`는 이제 그냥:
|
||||
|
||||
```lua
|
||||
Dispatch.process(inst, k, realv, index + 1) -- retractFrom 선행 호출 없음
|
||||
```
|
||||
|
||||
### 2-1. 이걸로 동시에 풀리는 것들
|
||||
|
||||
- **힌트의 타입이 보장됨** — 클로저에 값이 넘어가는 건 **오직 핸들러가
|
||||
같을 때뿐**이고, "같다"는 건 `getHandler(inst,k,v) == slot.handler`
|
||||
로 판정한 것이므로 `v`는 정의상 그 핸들러의 `isHandlable`을 만족함.
|
||||
1-1/1-2의 `None`/래퍼 오염이 **구조적으로 불가능**해짐. 지금의
|
||||
"`isX(hintValue)` 가드 필수" 일반 규칙 자체가 힌트의 타입 미보장을
|
||||
메우던 임시방편이었음이 드러남 — 그 규칙도 같이 없앨 수 있음.
|
||||
- **깊은 체인의 힌트 유실도 사라짐** — 힌트를 위에서 아래로 전파하는 게
|
||||
아니라, **각 레벨이 자기 재프로세스에서 자기 힌트를 받음**.
|
||||
`State<State<Tag>>`에서 바깥이 새 inner State를 내놓아도: index 2는
|
||||
StoreBind끼리 같으니 자기 클로저가 구독을 갈아타고, 재위임으로 내려간
|
||||
index 3은 TagHandler끼리 같으니 **진짜 `Tag` 객체를 힌트로 받아**
|
||||
`Contains` skip이 정상 동작. (앞 라운드에 "구조상 불가피"라고 적었던
|
||||
건 틀렸음 — 철거 선행 모델에서만 불가피했던 것.)
|
||||
- **`StoreBind` 구독 갈아타기 문제 없음**(사용자 지적: "다만 뭐가 문제인지
|
||||
모르겠어요") — "같은 핸들러면 **유지**"가 아니라 "같은 핸들러면 **자기
|
||||
클로저 → 자기 process**"라, 옛 구독 해제와 새 구독이 그 안에서 끝남.
|
||||
검토 중 제가 문제로 짚었던 것은 "유지"로 잘못 읽은 데서 나온 것.
|
||||
- **최상위는 애초에 안 돌아감** — 값이 안 바뀌면 Observer가 안 뛰므로
|
||||
재프로세스 자체가 없음.
|
||||
|
||||
### 2-2. 두 종류의 retract가 계약상 갈림 (사용자 정리)
|
||||
|
||||
> "새 프로세싱으로 인한 retract처리와, 단순 retract는 다르다"
|
||||
|
||||
- **단순 retract**(언마운트/전체 철거, `retractFrom`): 뒤따르는 `process`가
|
||||
없음. 힌트는 항상 `nil`. 핸들러는 자기 기여를 무조건 전부 걷어냄.
|
||||
- **재프로세싱**: 핸들러가 같으면 그 자리 클로저가 **새 값을 힌트로**
|
||||
받고, 다르면 그 자리부터 아래가 단순 retract 됨.
|
||||
|
||||
### 2-3. 전제 계약 — `inst` 부작용은 말단 핸들러만 (사용자 명시)
|
||||
|
||||
> "inst 에 실질적 처리를 가하는 동작은 항상 말단 핸들러 노드다"
|
||||
> "중간 노드는 단순 언워랩만 한다"
|
||||
|
||||
**기존 핸들러 전부가 이미 만족함**(확인함):
|
||||
|
||||
| 핸들러 | 위치 | `inst` 부작용 |
|
||||
|---|---|---|
|
||||
| `StoreBind` | 중간 | 없음(구독 + 재위임만) |
|
||||
| `NoneHandler` | 중간 | 없음(재위임만) |
|
||||
| `PropertyHandler` | 말단 | 프로퍼티 세팅 |
|
||||
| `TagHandler` | 말단 | `AddTag`/`RemoveTag` |
|
||||
| `AttributeKeyHandler` | 말단 | `SetAttribute` |
|
||||
| `SlotHandler` | 말단 | 마운트 |
|
||||
| `RefLeafHandler` | 말단 | `Ref:Set` |
|
||||
| `UICornerHandler` | 말단 | 자식 Instance 생성/제거 |
|
||||
| `AttributeGroupHandler` | 자기 체인에선 말단 | 없음(다른 키로 위임) |
|
||||
|
||||
새 제약을 거는 게 아니라 **이미 성립하는 성질을 계약으로 승격**하는 것.
|
||||
|
||||
## 3. 검토 중 정리된 것
|
||||
|
||||
### 3-1. `HandlerChanged` 마커는 불필요
|
||||
|
||||
"핸들러가 바뀜"은 **그 자리 retractor가 `nil` 힌트로 불린다는 사실 자체**로
|
||||
이미 표현됨. 별도 마커 값을 만들면 그것도 결국 "힌트로 넘어오는 정체불명의
|
||||
값"이 되어 1-1과 같은 문제를 되풀이함.
|
||||
|
||||
### 3-2. `oldValue`를 넘기자는 보완안 — 철회(사용자 지적)
|
||||
|
||||
> "이전 값인 oldValue 는 처음부터 클로저라 이미 본인이 알지 않아요?"
|
||||
|
||||
맞음. 클로저는 자기 `process` 호출의 `v`를 upvalue로 캡처하고 있고,
|
||||
힌트로 새 값을 받으므로 **old/new를 이미 둘 다 갖고 있음.** 문제였던 건
|
||||
오직 힌트의 타입 보장이고, 그건 2-1의 핸들러 선비교로 해결됨 —
|
||||
`chains`에 값을 따로 저장할 이유가 없음. (`chains`에 추가로 저장해야
|
||||
하는 건 **비교용 `handler` 하나뿐**.)
|
||||
|
||||
### 3-3. 중간(재위임) 핸들러에 붙는 작은 계약
|
||||
|
||||
같은 핸들러로 재프로세스될 때, **재위임하는 핸들러는 반드시 다시
|
||||
재위임해야 함.** 안 그러면 아래 인덱스가 고아로 남음(아무도 안 지움).
|
||||
`StoreBind`/`NoneHandler`는 항상 재위임하므로 지금은 위반 사례가 없지만,
|
||||
"조건부로만 재위임하는" 핸들러를 새로 만들면 그 자리에서
|
||||
`Dispatch.retractFrom(inst,k,index+1,nil)`을 직접 불러 아래를 정리해야 함.
|
||||
|
||||
## 4. 부수 효과 — Slot의 "해제 짝"은 애초에 없어도 됨 (사용자 지적)
|
||||
|
||||
`base/slot-plan.md`가 "`attachSlot`이 등록한 `Dispatch.setLength`/
|
||||
`setOffsetSource`에 대응하는 해제 짝이 없다"를 언마운트 전환의 실제
|
||||
작업량으로 꼽았는데, **새 함수가 필요 없음**:
|
||||
|
||||
> "옛 오너가 setLength/setOffsetSource 를 그냥 실행해도 된다는 생각.
|
||||
> retract에서 hint 를 보고 Slot이 아니면 그냥 setLength(...0...)
|
||||
> setOffsetSource(...None...) 될 수 있어요."
|
||||
|
||||
이건 이미 확정된 관용구 그대로임 — `bind-system-plan.md`의 "실제 마운트를
|
||||
하지 않는 위치는 `None`을 등록, `setLength`도 짝을 맞춰 `0`". 즉 **해제 =
|
||||
0/`None`으로 재등록**이고, 별도 unregister API가 아예 필요 없음.
|
||||
|
||||
**`state<state<Frame>>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된
|
||||
것"으로 수용**(사용자 판단) — `state<state<Tag>>`와 같은 범주이고, 평탄화
|
||||
도구(`research/operator-sugar-plan.md`)로 처리할 요소이며 실사용 케이스가
|
||||
드묾. Dispatch에 별도 배관을 넣지 않음.
|
||||
|
||||
## 5. 남은 열린 항목 — Attribute 이름 소유권 (**하나뿐, 사용자 확인 필요**)
|
||||
|
||||
사용자 판단:
|
||||
|
||||
> "이 방식으로 가면, 자연히 Attribute 의 소유권 충돌은 처리되네요.
|
||||
> 이미 처리된 인덱스에 대해 다시 프로세스 되는것 자체가 UB가 아니고,
|
||||
> 오류 처리가 필요한 곳이면 직접 처리하면 되니까요."
|
||||
|
||||
**앞부분(재프로세스가 UB 아님)은 동의 — 뒷부분("자연히 처리됨")은
|
||||
추적해보니 그렇지 않음.** 구체적으로:
|
||||
|
||||
그룹 A가 `Dispatch.process(inst, AttributeKey("foo"), sourceA, 1)`로
|
||||
등록해둔 상태에서 그룹 B가 같은 이름에 `sourceB`로 들어오면:
|
||||
인덱스 1의 현재 핸들러는 `StoreBind`이고 `sourceB`도 `StoreBind`에
|
||||
매치되므로 **"같은 핸들러"로 판정되어 조용히 갈아탐.** 그리고 나중에
|
||||
그룹 A의 클로저가 자기 이름들을 `retractFrom`할 때 **그룹 B의 바인딩을
|
||||
대신 철거**함(교차 오염). 즉 예전 "조용한 last-write-wins"가 그대로
|
||||
돌아옴 — 이번 감사에서 고쳤던 바로 그 증상.
|
||||
|
||||
**즉 "직접 처리하면 되니까"가 맞고, 그 '직접 처리'를 실제로 무엇으로
|
||||
할지가 유일하게 남은 결정.** 후보:
|
||||
|
||||
- **(a) 이름별 claimant `Relate`를 Attribute 쪽에 둠** — 2026-08-13
|
||||
네 번째 세션에 `owners`라는 이름으로 만들었다가 기각됐던 그것.
|
||||
**당시 기각 사유는 지금은 해당 없음**: 그때 버그는 "소유권 반납이
|
||||
`process`의 `v==nil` 분기에만 있어서 그룹이 이름을 통째로 놓는 경로가
|
||||
그 분기를 안 타 옛 소유권이 안 지워짐"이었는데, 지금은 **클로저가 항상
|
||||
불리고 거기서 반납**하면 되므로 그 구멍이 구조적으로 없음. 실질 6줄
|
||||
정도.
|
||||
- **(b) 감지 포기, 문서로만 금지** — 사용자 코드 실수이므로 UB로 두는 것.
|
||||
다만 증상이 "조용한 오작동 + 교차 오염"이라 다른 UB들(즉시
|
||||
스택오버플로/즉시 error)보다 훨씬 나쁨.
|
||||
- **(c) `Dispatch`에 claimant 개념을 일반화** — 이번에 걷어낸 방향이라
|
||||
다시 넣는 건 반대.
|
||||
|
||||
**권고: (a).** 기각 사유가 새 모델에서 소멸했고, 소유권 판정을 필요로
|
||||
하는 유일한 핸들러에만 국소적으로 두는 게 "Dispatch는 diff만 한다"는
|
||||
이번 방향과도 맞음.
|
||||
|
||||
## 6. 반영 범위 (확정 시)
|
||||
|
||||
- `bind-system-plan.md` — `Dispatch.process` 의사코드, "Dispatch 체인" 절,
|
||||
"핸들러 계약"(힌트 타입 보장 추가, `isX(hintValue)` 가드 규칙 삭제),
|
||||
"Handler 작성 체크리스트" 3번 항목, `StoreBind` 예시(선행 `retractFrom`
|
||||
삭제), `NoneHandler` 예시, "hintValue는 직속 1단계에만" 항목 삭제.
|
||||
- `tag-plan.md` — 힌트가 항상 `Tag`임이 보장되므로 `isTag(hintValue)`
|
||||
가드 삭제, 깊은 중첩 캐비엇 삭제.
|
||||
- `slot-plan.md` — 4절대로 "해제 짝 필요" 서술 정정, 언마운트 경로에
|
||||
`setLength(0)`/`setOffsetSource(None)` 명시.
|
||||
- `attribute-plan.md` — 5절 결정 반영, `process`/클로저 모양 재확정.
|
||||
|
|
@ -291,6 +291,79 @@ Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는
|
|||
재검토. 지금은 이름도 모양도 구체화 안 함 — 착수 시점에 이 문서의
|
||||
`Animate`/`Sum` 패턴을 그대로 참고.
|
||||
|
||||
## 열린 질문 — 중첩 State 평탄화 `State<State<T>>` → `State<T>` (2026-08-13 여섯 번째 세션, 사용자 제시, 백로그)
|
||||
|
||||
**배경**: 2026-08-13 다섯 번째 세션의 인덱스 기반 `Dispatch` 재설계로
|
||||
`State<State<T>>`가 UB에서 **정상 지원 대상**이 됐음(각 재귀 단계가
|
||||
다른 인덱스를 써서 슬롯 충돌이 없어짐, `base/bind-system-plan.md`
|
||||
"Dispatch 체인" 절). 하지만 **동작한다고 해서 권장 방향인 건 아님**
|
||||
(사용자 판단: "UB는 아니지만 우리가 원치 않는 방향인건 맞습니다").
|
||||
|
||||
**왜 원치 않는가 — 단순 취향이 아니라 실제 기능 손실이 있음**:
|
||||
`Dispatch.retractFrom(inst,k,index,v)`는 힌트 `v`를 정확히 `index`
|
||||
자리에만 넘기고 더 깊은 인덱스엔 `nil`을 넘김. 그래서 `State<Tag>`
|
||||
(한 겹, StoreBind@1 → TagHandler@2)에서는 재발행 시 TagHandler가
|
||||
`hintValue=newTag`를 **확실히** 받아 깜빡임 방지가 동작하지만,
|
||||
`State<State<Tag>>`(StoreBind@1 → StoreBind@2 → TagHandler@3)에서
|
||||
**바깥** store가 재발행하면 TagHandler는 `nil`을 받아 `RemoveTag`→
|
||||
`AddTag` 왕복이 실제로 일어남. 깊이가 늘수록 힌트 기반 최적화
|
||||
(`Tag`의 `Contains`, `Ref`/`Slot`의 identity 비교)가 전부 무력화됨.
|
||||
|
||||
**아이디어(착수 안 함)**: 중첩을 Dispatch 층에서 감내하는 대신, 값
|
||||
층에서 **평탄화하는 콤비네이터**를 제공 — `State<State<T>>`를 받아
|
||||
`State<T>`를 돌려주는 join/flatten(하스켈 모나드 `join`, RxJS
|
||||
`switchAll`/`switchMap`에 대응). 그러면 체인이 항상 한 겹으로 유지돼
|
||||
힌트도 안 잃고, 사용자 의도("안쪽 값이 바뀌면 그걸 따라간다")도 더
|
||||
직접적으로 표현됨.
|
||||
|
||||
- 2026-08-13 두 번째 세션의 Haskell 비교에서 이미 **"Monad bind/join이
|
||||
`StoreBind`/`Slot:Single`/`NoneHandler`에 각자 따로 재구현돼 있는
|
||||
미일반화 후보"**로 식별해뒀던 것과 같은 자리 — 그 세션은 "착수 안 함"
|
||||
으로 남겼고, 이번에 구체적 동기(힌트 유실)가 붙은 것.
|
||||
**모양(2026-08-13 여섯 번째 세션 후속, 사용자 구체화)**: 이 카탈로그의
|
||||
다른 항목들과 달리 **`Operator.*` 네임스페이스가 아니라 `State`의
|
||||
메소드로 제공되어야 할 것으로 보임** — `state:Flatten()` 또는
|
||||
`state:Flat()`(이름 미정). 이유: `Sum`/`Not`류는 인자를 받아 값을 만드는
|
||||
순수 함수라 자유 함수가 자연스럽지만, 평탄화는 **특정 State 노드 하나를
|
||||
받아 그것을 따라가는 새 노드**를 만드는 것이라 `:Compute`/`:With`와
|
||||
같은 층위의 체이닝 메소드가 맞음.
|
||||
|
||||
- 대상 타입은 `State<State<T> | T>` — 즉 **안쪽이 State일 수도, 그냥 값일
|
||||
수도 있는 섞인 경우까지 흡수**해야 함(사용자 명시). 동작: 바깥 State의
|
||||
변경을 리슨하다가, 흘러나온 값이 `T`면 그대로 `State<T>`로 내보내고,
|
||||
`State<T>`면 그 안쪽을 따라가는 `State<T>`를 내보냄.
|
||||
|
||||
**⚠️ 핵심 난점 — 반환 노드가 동적 의존성을 가짐(사용자 지적).**
|
||||
이게 이 항목을 단순 슈가로 못 만드는 이유이자, 백로그에서 따로 더
|
||||
파야 하는 지점:
|
||||
|
||||
- quad는 **암묵적 자동 추적을 기각**했고(`base/bind-system-plan.md`),
|
||||
의존성은 `:With`로 **정적으로** 선언하게 돼 있음. 게다가 "`:With`의
|
||||
동적 의존성 미지원"은 2026-08-12 열여덟 번째 세션에 **의도된
|
||||
트레이드오프로 확정**됨(`research/framework-comparison-findings.md`) —
|
||||
State immutable 가정과 정면으로 부딪힌다는 이유.
|
||||
- 그런데 평탄화 노드는 본질적으로 **안쪽 State가 바뀔 때마다 구독
|
||||
대상을 갈아타야** 함 = 의존성 집합이 런타임에 변함. 즉 이 도구는
|
||||
방금 그 "의도적 비지원" 결정의 **유일한 정당한 예외**를 요구함.
|
||||
- 그래서 확정 전에 답해야 할 것:
|
||||
1. 동적 의존성을 **이 노드 안에만 가둘 수 있는가** — 바깥에서 보면
|
||||
여전히 평범한 `State<T>` 하나(정적 의존성 1개)이고, 구독 갈아타기는
|
||||
노드 내부 구현 디테일로 숨겨지는가? 숨겨진다면 "동적 With 미지원"
|
||||
결정과 실제로는 안 부딪힘(그 결정은 *사용자가 선언하는* 의존성
|
||||
목록에 대한 것이므로).
|
||||
2. 안쪽 State 교체 시 **옛 구독 해제 타이밍**과 `bindLifetime` 귀속 —
|
||||
이 노드는 `inst`에 안 묶인 순수 값 계층이라 `:Subscribe()` 계열
|
||||
규칙을 따라야 하는지, 아니면 다운스트림이 살아있는 동안만 유지되는
|
||||
별도 규칙이 필요한지.
|
||||
3. 그래서 결국 **`:Apply` 위의 순수 슈가가 아니라 진짜 새 프리미티브**
|
||||
인지 — 지금 판단으로는 상태를 갖는 노드라 후자에 가까움. 그렇다면
|
||||
이 문서(순수 슈가 카탈로그)가 최종 거처가 아닐 수도 있음.
|
||||
|
||||
**우선순위**: 백로그. `State<State<T>>`가 이제 정상 동작하므로 이게
|
||||
없다고 막히는 건 없고, 힌트 유실도 흔한 경로(한 겹)엔 영향이 없음.
|
||||
다만 위 난점 때문에 **다른 카탈로그 항목들보다 설계 비용이 확실히 큼** —
|
||||
착수 시 "슈가 하나 추가"로 접근하지 말 것.
|
||||
|
||||
## 우선순위
|
||||
|
||||
**맨 마지막.** 없어도 quad는 기능상 완전하고, 함수 간 의존이 없어 나중에
|
||||
|
|
|
|||
|
|
@ -79,8 +79,10 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌
|
|||
### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 — [해소됨, 2026-08-08 세 번째 세션]
|
||||
|
||||
**해소**: `Dispatch`가 `(inst,k)`별 핸들러 체인(순서 있는 배열, `chains`)을
|
||||
직접 소유하고, `Dispatch.retractUnder(inst,k,keep,v)`가 꼬리부터 `keep`
|
||||
앞까지 정리해주는 걸로 확정 — 아래 원래 제안(`Dispatch/StoreBind.luau`가
|
||||
직접 소유하고, `Dispatch.retractFrom(inst,k,index,v)`가 꼬리부터 `index`
|
||||
까지 정리해주는 걸로 확정(2026-08-08 확정 당시 이름은 `retractUnder`이고
|
||||
체인이 핸들러 배열이었음 — 2026-08-13 다섯 번째 세션에 인덱스 기반으로
|
||||
재설계되며 개명, 결론 자체는 유지) — 아래 원래 제안(`Dispatch/StoreBind.luau`가
|
||||
"마지막 선택된 핸들러"를 직접 들고 있는 방식)은 재귀/래핑 핸들러가
|
||||
여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가
|
||||
슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이
|
||||
|
|
|
|||
|
|
@ -0,0 +1,490 @@
|
|||
# 2026-08-13 여섯 번째 세션 — c33ae04 커밋 전체 감사, 인덱스 재설계 의사코드의 실제 버그 4건 + 전파 누락 정리
|
||||
|
||||
사용자 요청: "c33ae04 커밋이 정확한지 봐줘. 많은게 바뀌여서 정확한지 검토해야함.
|
||||
너가 직접 봐야할듯. 전체 문서에 잘못된 부분이 있는지, 로직이 이상한게 있는지.
|
||||
더 나은 로직이 있는지 전체 감사 처리를 해줘" — 서브에이전트 위임이 아니라
|
||||
메인 컨텍스트에서 직접 읽으라는 명시적 지시라 그렇게 진행.
|
||||
|
||||
## 총평
|
||||
|
||||
재설계 **방향 자체는 옳음** — 인덱스 기반 재추적, `State<State<T>>` UB 해소,
|
||||
`retractUnder`/`retractSelfAndUnder` 통합, 체크포인트 패턴 철회, `Relate`
|
||||
대량 정리 전부 근거가 맞고 `archive/checkpoint-handler-pattern-reversed.md`도
|
||||
정확함. 문제는 **그 결정에 맞춰 새로 쓴 의사코드들이 손 트레이싱을 안
|
||||
거쳤다는 것** — 네 번째 세션이 "합성 시나리오를 pseudocode에 손으로 대입해
|
||||
버그 찾기" 라운드였는데, 정작 그 결과로 다섯 번째 세션이 새로 쓴 코드에는
|
||||
같은 방법을 안 돌린 채 커밋됨.
|
||||
|
||||
## 발견된 실제 버그 4건
|
||||
|
||||
### 1. `Dispatch.process`가 하위 위임 retractor를 통째로 유실 (치명)
|
||||
|
||||
`bind-system-plan.md`의 `chains` 의사코드가 `chains:SetStrong(inst,k,list)`를
|
||||
`h.process` **뒤**에 두고 있었고, list 확보도 `chains:GetStrong(...) or {}`
|
||||
였음. `h.process`가 내부에서 재귀 `Dispatch.process(...,index+1)`을 부르는 게
|
||||
정상 경로(StoreBind/NoneHandler)인데, 최초 마운트 시점엔 chains에 아직
|
||||
아무것도 없어 **재귀 호출이 `or {}`로 자기만의 새 테이블을 만들어 저장한 뒤
|
||||
바깥이 그걸 덮어씀.**
|
||||
|
||||
`Frame { Text = state }` 트레이싱:
|
||||
|
||||
| 단계 | chains |
|
||||
|---|---|
|
||||
| process(idx1) StoreBind 진입, list={} (미저장) | 없음 |
|
||||
| ㄴ observer 즉시 1회 → process(idx2) Property | `{[2]=noop}` (별도 테이블) |
|
||||
| StoreBind 반환, `list[1]=r1`, SetStrong(list) | **`{[1]=r1}`** — 앞의 것 유실 |
|
||||
|
||||
증상: 이후 첫 재발행에서 `retractFrom(inst,k,2,...)`가 `#list==1`이라 아무것도
|
||||
안 부름. Property(no-op)면 무해하지만 **인덱스 2가 Slot이면 이전 서브트리가
|
||||
파괴 안 된 채 새 서브트리가 마운트(자식 중복)**, Ref면 이전 Ref가 stale하게
|
||||
남음. 즉 이 재설계의 핵심인 다단 체인 정리가 최초 마운트 경로에서 통째로
|
||||
깨져 있었음.
|
||||
|
||||
수정: `SetStrong`을 `h.process` 위로 hoist. 추가로 `h.process` 호출 전에
|
||||
no-op 점유 마커를 박도록 함 — 재귀 중 list에 구멍이 생기면 `#list`가
|
||||
Lua에서 미정의이고(hole 있는 테이블), 같은 index 재진입 버그도 가드에
|
||||
안 걸리기 때문.
|
||||
|
||||
### 2. Attribute 그룹의 소유권 충돌 감지가 실제로는 절대 안 걸림 (치명)
|
||||
|
||||
`attribute-plan.md` "이름 소유권" 절은 "점유 체크가 소유권 충돌 감지를
|
||||
그대로 대신함"을 이번 재설계의 핵심 근거로 선언하는데, 정작 "메커니즘"
|
||||
절 코드가
|
||||
|
||||
```lua
|
||||
Dispatch.retractFrom(inst, key, 1, source) -- 인덱스 1을 무조건 비움
|
||||
Dispatch.process(inst, key, source, 1) -- → 점유 error가 날 수가 없음
|
||||
```
|
||||
|
||||
이라 **그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음**
|
||||
(그룹 B가 그룹 A의 바인딩을 파괴하고 이김, 나중에 A의 클로저가 돌면 B의
|
||||
바인딩을 대신 철거). 이 절이 없앴다고 선언한 바로 그 문제.
|
||||
|
||||
(그룹↔직접 리터럴 쓰기 방향은 정상 작동했음 — 배열파트가 먼저라 그룹이
|
||||
점유하고, 해시파트 직접 쓰기는 `retractFrom` 없이 `process`만 부르므로.)
|
||||
|
||||
수정: `retractFrom`을 `process`에서 빼고 **반환 클로저가 자기가 등록한 이름
|
||||
전부를 철거**하도록 이동. 생존 이름도 매 사이클 철거→재등록되는 비용이
|
||||
생기지만(구독 해제+재구독, 같은 값 `SetAttribute` 1회) 이 문서가 이미
|
||||
"값 비교는 안 함"을 확정해둬서 결이 같음. **`Tag`식 `hintValue == v` 조기
|
||||
반환을 여기 넣으면 안 됨** — 클로저가 아무것도 안 걷어낸 채 다음 `process`가
|
||||
같은 인덱스를 잡으려 들어 자기 자신에게 점유 error를 냄.
|
||||
|
||||
### 3. `SlotHandler`가 claim 실패에도 파괴적 클로저를 반환 (치명)
|
||||
|
||||
`claimOwner`의 `current == ownerKey → return false` 분기가 **두 개의 서로 다른
|
||||
상황을 구분 못 함**:
|
||||
- 같은 `(inst,k)`의 spurious 재발행 (정상, no-op이어야)
|
||||
- **같은 `inst`의 다른 위치** (`Frame { slot, slot }`) — error여야 하는데 false
|
||||
|
||||
후자에서 attach는 건너뛰면서 파괴적 클로저는 그대로 반환해서, 철거 시
|
||||
`destroySlotTree` 두 번 + `unbindLifetime` 짝 어긋남 + `releaseOwner`가
|
||||
(이번 세션에 새로 넣은 엄격 버전이라) error로 터짐. 구 설계는 `kSlotMap`에
|
||||
안 적힌 자리의 `retract`가 자연히 no-op이라 우연히 막혀 있었고, `kSlotMap`
|
||||
제거가 그 방어를 같이 걷어낸 회귀.
|
||||
|
||||
**감사 중 오답 하나 — 기록해둠**: 처음엔 "claim 실패 시 no-op 클로저를
|
||||
반환"으로 고치려 했는데 이건 더 나쁨 — `retractFrom`은 클로저가
|
||||
early-return하든 말든 체인에서 **항상 소비**하므로, spurious 사이클에서
|
||||
no-op을 심으면 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라짐.
|
||||
원래의 단일 파괴적 클로저 구조가 맞고, 고칠 곳은 `claimOwner`뿐이었음.
|
||||
|
||||
### 4. `Ref` retractor가 자기 dedup을 스스로 무력화
|
||||
|
||||
```lua
|
||||
if hintValue ~= v then v:Set(nil) end
|
||||
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end -- 무조건
|
||||
```
|
||||
|
||||
spurious 재발행에서 `Set(nil)`은 건너뛰지만 relate를 지워서, 이어지는
|
||||
`process`의 `old ~= v`가 항상 참 → `v:Set(inst)` 재실행 → 콜백 헛 재통지.
|
||||
바로 아래 산문이 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음.
|
||||
사용자가 이 건은 즉시 확인해줌("4. 는 확인했어요 맞습니다").
|
||||
수정: relate 정리를 `if hintValue ~= v` 블록 안으로.
|
||||
|
||||
## 사용자 제기 — Slot-in-Slot / `state<Slot>` 소유권
|
||||
|
||||
> "slot in slot 을 생각해보면 a=Slot {}; Slot{a,a} 도 UB 여야할텐데,
|
||||
> Slot{state<Slot>} 또한 가능합니다. ... Slot in slot 도 안전해야하는데,
|
||||
> 저는 후자가 맞는듯 합니다."
|
||||
|
||||
조사 결과 **후자가 맞고 실제로 성립하지만, 추가 수정 셋이 필요했음**:
|
||||
|
||||
- **N1**: `rawAdd`가 `claimOwner`의 반환값을 아예 안 봄 → `Slot { a, a }`가
|
||||
조용히 통과해 `_elements={a,a}`, `attachSlot`이 두 번 불려 부모
|
||||
`lengthList`에 같은 Slot이 두 번 계산되고 `slot.Offset`도 두 번째가
|
||||
덮어써 첫 `Source`가 고아가 됨.
|
||||
→ **nested엔 "재클레임"이라는 개념이 애초에 없음**(reconcile은 항상
|
||||
`rawRemove`→`rawAdd` 순서, `rawMove`/`rawSwap`은 클레임 미접촉)이라
|
||||
엄격 error가 맞음. top-level만 `claimOwnerAt(element,inst,k)`으로 위치까지
|
||||
봐서 spurious를 구분.
|
||||
- **N2 (위치 키잉이 안전한 이유, 사용자 확인)**: "바깥 slot 안에서의 인덱스는
|
||||
여전할거예요. 오직, flatten 되어진 상태에서의 위치만 달라질 뿐이고 ...
|
||||
offset/index 구현이 그걸 증명해요" — 물리 배치 변동은 전부 `offset`이
|
||||
흡수하도록 설계돼 있고, top-level의 `k`는 props 배열 리터럴 위치라 더욱
|
||||
고정. nested는 `Move`/`Swap`/`Splice`가 인덱스를 밀지만 위 결론대로
|
||||
위치를 안 쓰므로 무관.
|
||||
- **N3**: `rawRemove` 의사코드에 `releaseOwner`가 아예 없었음(산문 쪽은
|
||||
있다고 명시 — 코드/산문 불일치).
|
||||
- **N4**: `destroySlotTree`가 자식 `releaseOwner`를 안 하고 `_mounted`/
|
||||
`_mountedInst`도 안 되돌림 → `elementOwner` 값이 weak라 "언젠간 사라지지만
|
||||
**언제인지가 GC 타이밍에 달려서**" 그 전에 같은 element를 재사용하면
|
||||
"이미 마운트됨" error가 비결정적으로 터짐.
|
||||
|
||||
`state<Slot>` 자체는 구조가 `outer._elements[i] = sub`(래퍼 Slot, 영구 고정)
|
||||
→ `sub:Single(state)` → `:List` reconcile이 안쪽만 교체이고, reconcile이
|
||||
`rawRemove(prev)` 다음 `rawAdd(result)` 순서라 소유권이 release→claim으로
|
||||
정확히 갈림. **즉 "래퍼가 불변이라 괜찮다"가 아니라 nested CRUD의
|
||||
release→claim 규율 자체가 안전성을 만듦** — 그래서 손으로 중첩한
|
||||
`Slot{Slot{Slot}}`도 같은 규칙 하나로 동작. 이 결론을 `slot-plan.md`에
|
||||
표와 함께 별도 절로 기록.
|
||||
|
||||
## 그 외 수정
|
||||
|
||||
- `AttributeGroupHandler.process(inst, index, v)` — 코퍼스에서 유일하게 계약
|
||||
(`process(inst,k,v,index)` 4-인자)과 안 맞던 시그니처. 배열 위치를 하필
|
||||
`index`로 불러 새 `index` 파라미터와 충돌하기까지 했음.
|
||||
- `Dispatch.drive`의 진입 인덱스(`1`)가 어디에도 안 적혀 있었음.
|
||||
- retractor 안에서 **같은 키**에 `retractFrom`을 부르는 것도 `process`처럼
|
||||
금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 — 원래는 "다른
|
||||
키에 대해서는 문제없음"이라는 괄호로만 암시됐음.
|
||||
- 깊은 체인에서 hint 유실: `retractFrom`은 `v`를 `i == index`에만 넘기므로
|
||||
`State<State<Tag>>`의 바깥 재발행에선 TagHandler가 `nil` 힌트를 받아
|
||||
RemoveTag→AddTag 깜빡임. 구조상 불가피(바깥은 안쪽 값을 모름)해서
|
||||
`tag-plan.md`의 깜빡임 방지 주장을 "직속 위임 1단계 한정"으로 범위 축소.
|
||||
- 미문서화 접근자 `Tag:Names()`/`Attribute:NameMap()`을 각 API 절에 추가.
|
||||
|
||||
## 전파 누락 정리 (커밋이 안 건드린 것들)
|
||||
|
||||
- **`ROADMAP.md`가 전혀 갱신 안 됨** — CLAUDE.md가 "구현 순서의 소스"라고
|
||||
못박은 문서인데 M2 `Dispatch/init.luau` 항목이 아예 2026-08-08 이전 모델
|
||||
("이전 담당자와 다르면 그 `retract`")이었고, `chains`도 핸들러 배열,
|
||||
`retractUnder`도 그대로. M2/M4/M7 전부 새 모델로 갱신 + 위 버그 1을 다시
|
||||
내지 않도록 "구현 시 반드시 지킬 것" 체크리스트 추가.
|
||||
- **base 안에서 계약 개수가 모순** — `store-semantics.md`는 이번에 3종으로
|
||||
고쳤는데 `module-lifecycle-plan.md`/`component-composition-plan.md`는
|
||||
4종, `ui-shorthand-plan.md`/`onchange-plan.md`/`tween-plan.md`/
|
||||
`lifecycle-pattern.md`는 별도 `retract` 필드 전제. 전부 갱신
|
||||
(`onchange-plan.md`는 단순 이름이 아니라 Connection Disconnect 로직이
|
||||
반환 클로저로 이동해야 하는 실질 변경이었음).
|
||||
- `luau-test/04`가 **설계와 정반대를 검증 중**이었음 — 파일명부터
|
||||
`retractUnder`이고, 다섯 번째 세션이 **없앤** "중복 핸들러 즉시 error"
|
||||
가드를 테스트하는데 `luau-test/README.md`는 "[2026-08-13 재작성] 신규
|
||||
가드가 즉시 error하는지"라고 최신인 척 적어놨음. `04-dispatch-chain-
|
||||
retractFrom.luau`로 전면 재작성 — 인덱스 기반 3단 체인, 깊은 쪽부터
|
||||
정리, hint 전달 범위 + **버그 1을 재현하는 음성 대조군**(`SetStrong`을
|
||||
`process` 뒤로 옮기면 체인 깊이가 1로 무너지는지)을 포함. 옛 04의
|
||||
no-op retract 스텁이 사각지대였던 전례를 반복하지 않도록 이번엔
|
||||
retractor가 실제로 구독을 끊음.
|
||||
- `luau-test/19` B/C 섹션이 폐기된 설계(`rawNew`+`owners`, 3분기
|
||||
`claimOwner`)를 검증 중 — 파일 헤더에 무엇을 어떻게 다시 써야 하는지
|
||||
배너로 명시(재작성 자체는 미착수).
|
||||
- `slot-plan.md`의 GC 주의 문단/`relate-plan.md`가 `kSlotMap`/`slotOwner`를
|
||||
현재 설계처럼 서술하던 것을 역사 표시로 정정(둘 다 지금은 없음 — 일반
|
||||
규칙은 계속 유효).
|
||||
- `.claude/README.md`의 `SlotHandler.retract` 언급 제거 + 세 행에 이번
|
||||
감사 결과 반영.
|
||||
|
||||
## 남은 것
|
||||
|
||||
- `luau-test/19` B/C 섹션 재작성(헤더에 지시만 남김).
|
||||
- 스파이크 실측 자체는 여전히 미실행(이 환경에 `luau` 바이너리 없음) —
|
||||
새로 쓴 `04`의 음성 대조군이 실제로 깊이 1로 무너지는지 확인하면
|
||||
버그 1의 재현·수정이 실측으로 닫힘.
|
||||
|
||||
## 후속 라운드 (같은 세션, 사용자 추가 질문)
|
||||
|
||||
### `State<Slot?>`의 사라짐/재등장, 그리고 포탈
|
||||
|
||||
사용자 질문: `stateSlot: State<Slot?>`를 `Slot { stateSlot }`에 넣고
|
||||
지웠다 나타나게 하면 문제 없는가. 그리고 `Get()`으로 뽑아두고 `Set()`으로
|
||||
갈아끼운 뒤 뽑은 걸 다른 데 넣을 수 있는가 — 되면 포탈이 해결됨.
|
||||
|
||||
- **소유권 bookkeeping은 정상**(이번 감사 수정 이후) — reconcile이
|
||||
`rawRemove`(→`releaseOwner`) / `rawAdd`(→`claimOwner`)로 깨끗이 갈림.
|
||||
- **그러나 Slot 자체가 파괴됨** — reconcile이 쓰는 게 `rawRemove`(제거
|
||||
**+ 파괴**)라 `nil`이 되는 순간 `destroySlotTree`가 자식 Instance를
|
||||
`:Destroy()`함. 같은 Slot 객체를 `nil`↔`slotA`로 왕복시키면 두 번째
|
||||
등장부터 껍데기만 재마운트됨. 기존 "폐기, 옮기지 않음" 정책의 직접
|
||||
귀결이지 이번 감사로 바뀐 게 아님.
|
||||
- **포탈은 현재 불가, 그러나 부품은 이미 다 있음** — 막는 건 소유권
|
||||
규칙이 아니라 "제거 = 파괴"라는 reconcile의 선택 하나뿐이고,
|
||||
`Extract`/`ExtractAll`/`Splice`가 이미 비파괴로 확정돼 있고
|
||||
`attachSlot`이 재마운트를 구조적으로 이미 지원함(이번에 넣은
|
||||
`destroySlotTree`의 `_mounted` 복원이 마침 그 전제 조건이기도 함).
|
||||
**"포탈은 별도 메커니즘이 필요하다"는 기존 전제가 틀렸을 가능성이
|
||||
큼** — 남는 실제 작업은 (1) 비파괴를 어디에 opt-in으로 열지, (2)
|
||||
`attachSlot`의 등록(`setLength`/`setOffsetSource`/자식 observer)에
|
||||
대응하는 **해제 짝**이 지금 없다는 것. 확정은 사용자 몫이라
|
||||
`question.md` 0-A로 올리고 `slot-plan.md`에 상세 기록.
|
||||
|
||||
### `State<Tag>`는 힌트를 확실히 받는가 — 받음
|
||||
|
||||
`retractFrom`은 힌트를 `i == index` 자리에만 넘김. 한 겹
|
||||
(`StoreBind@1` → `TagHandler@2`)에서는 StoreBind가
|
||||
`retractFrom(inst,k,2,realv)`를 부르므로 정확히 그 핸들러에 걸림 —
|
||||
**유실 없음**. 두 겹 이상에서 바깥이 재발행할 때만 안쪽이 `nil`을 받음.
|
||||
이 계약을 `bind-system-plan.md`에 명시적으로 못박음(원래는 의사코드에만
|
||||
있었음).
|
||||
|
||||
### 사용자 제안 — 핸들러 identity를 저장해두고 값을 한 겹 풀어 내려보내기
|
||||
|
||||
기각. 사용자 스스로 지적한 이유(=`process`와 retractor가 1:1이 아니고
|
||||
retract만 나는 경로가 정상적으로 존재해서 내려보낼 "새 값"이 없는
|
||||
경우가 있음)에 더해, 더 근본적인 이유가 있음: 값을 한 겹 풀려면
|
||||
teardown 도중 `innerState:Get()`을 **투기적으로** 호출해야 하는데,
|
||||
State는 pull-recompute라 실제 재계산을 유발하고 곧이어 `process`가 다시
|
||||
`:Get()`을 불러 같은 사이클에 이중 계산이 됨. 게다가 그렇게 얻은 값은
|
||||
추정이라 실제 `process` 시점 값과 다를 수 있음("값 비교/캐싱 금지"
|
||||
원칙과 충돌).
|
||||
|
||||
**대신 채택 방향(사용자 제시)**: 값 층에서의 평탄화
|
||||
(`State<State<T>>` → `State<T>`). 체인이 한 겹으로 유지되면 위 보장이
|
||||
그대로 적용되므로 Dispatch에 특수 배관이 필요 없어짐. 백로그로만 등록
|
||||
(`research/operator-sugar-plan.md`) — 2026-08-13 두 번째 세션의 Haskell
|
||||
비교가 이미 "Monad join이 미일반화 후보"로 짚어둔 자리에 구체적 동기가
|
||||
붙은 것. `State<State<T>>`는 UB는 아니지만 권장 방향도 아니라는 사용자
|
||||
판단을 그 문서에 명시.
|
||||
|
||||
### 재발 방지 조치 (사용자 요청)
|
||||
|
||||
"새로운 모델로 인해 생겨날 수 있는 흔한 실수들이 재발하지 않도록 잘
|
||||
정리해주세요. Relate 나 Dispatch/Handler 전반에서 더 실수가 나오지
|
||||
않도록 조치가 필요해보여요."
|
||||
|
||||
- `bind-system-plan.md`에 **"Handler 작성 체크리스트 — 새 모델에서 실제로
|
||||
반복된 실수들"** 절 신설(7항목). 이번 세션 버그 4건이 서로 다른 문서에
|
||||
있으면서도 같은 종류의 착각에서 나왔다는 관찰이 출발점 — 특히 (1)
|
||||
"클로저는 early-return해도 체인에서 소비된다"(no-op 반환 유혹), (2)
|
||||
"다른 키로 위임하며 그 키를 미리 `retractFrom`하지 말 것"(점유 체크
|
||||
무력화), (3) `hintValue`의 세 가지 함정(nil 아님/타입 미보장/깊은
|
||||
인덱스엔 안 옴)이 실제로 밟힌 것들.
|
||||
- `relate-plan.md`에 **"언제 `Relate`를 쓰고 언제 쓰면 안 되는가"** 절
|
||||
신설 — 클로저 캡처로 충분한 경우 vs 진짜 필요한 경우의 경계, 그리고
|
||||
"정리 조건을 실제 정리와 묶을 것"(Ref 버그), "weak라고 GC에 기대지
|
||||
말 것"(Slot 버그) 두 규칙.
|
||||
|
||||
## 세 번째 라운드 (같은 세션) — 사용자 설계 결정 3건
|
||||
|
||||
### A. `State<Slot>` 교체는 파괴가 아니라 **언마운트** (확정, 앞 라운드 결론을 뒤집음)
|
||||
|
||||
앞 라운드에서 "reconcile이 `rawRemove`(파괴)를 쓰므로 포탈 불가"라고
|
||||
보고했더니, 사용자가 **설계 자체를 뒤집음**: "state에서 slot 빼내면 빠질
|
||||
수 있는게 나은듯. 애초에 스플라이싱을 지원하는데?"
|
||||
|
||||
근거:
|
||||
- **`state<Frame>`가 이미 그렇게 동작함** — 다른 값으로 바꿔도 이전
|
||||
Frame을 quad가 지우지 않고 unmount만 함. `State<Slot>`만 다를 이유가
|
||||
없음. `Ref`("Destroy 무관")/`Attribute`("명시적 `None`으로만") 철학과도
|
||||
같은 결.
|
||||
- 비파괴 추출(`Extract`/`ExtractAll`/`Splice`)은 **이미 지원되는 개념**.
|
||||
- "뽑아냈으면 리프의 소유가 아닌 게 맞아보임. 명시 `:Destroy()`를 하도록
|
||||
유도하는 게 이로운 것 같음."
|
||||
- "슬롯은 '들고 있다 죽으면' 같이 소멸한다" — GC-native 그대로.
|
||||
|
||||
부수 효과(사용자 지적): 하위 요소까지 `bindLifetime`하고 `canExecute`를
|
||||
확인하게 하면, "nested로 마운트 후 Instance를 제거하고 그 Slot을 뽑아
|
||||
쓰려는" 경로가 **별도 방어 없이 자연히 막힘** — 기존 게이트를 한 층 더
|
||||
촘촘히 적용하는 것뿐.
|
||||
|
||||
**그래서 포탈은 별도 기능이 아니라 이 결정의 귀결이 됨** — 앞 라운드에서
|
||||
"opt-in으로 열지"를 열린 질문으로 뒀는데, 기본 동작이 되면서 그 질문
|
||||
자체가 사라짐. **남은 실제 작업은 하나**: `attachSlot`이 등록하는 것들
|
||||
(자식 observer `bindLifetime`, 옛 owner의 `setLength`/`setOffsetSource`)에
|
||||
대응하는 **해제 짝이 지금 없음**.
|
||||
|
||||
곁들여 확정된 UB: **`Set`으로 덮어쓰기 *전에* 이전 값을 직접
|
||||
`Destroy()`하는 것**(= `state<Frame>`에서 먼저 `frame:Destroy()`하고
|
||||
`Set`하는 것과 같은 문제). 순서는 항상 `Set`(언마운트) → 그 다음 정리.
|
||||
|
||||
### B. `dispose(any)` 신설 방향 (사용자 제안)
|
||||
|
||||
위 UB를 "조심하세요"로만 두지 않기 위해, base 탑레벨 `dispose(value)`를
|
||||
제공 — "이 값을 지금 확실히 없앤다, 아직 마운트돼 있으면 먼저 안전하게
|
||||
떼어낸 뒤 파괴". 사용자 논증이 깔끔했음: **"이미 `a=Frame{}; Frame{a}
|
||||
Frame{a}`가 에러나도록 하기로 했으니, 어디 마운트되었냐가 따져지고,
|
||||
그래서 이미 가능한 일"** — `elementOwner`를 거꾸로 읽으면 되므로 새
|
||||
부기가 필요 없음. 시그니처/대상 범위/`unbindLifetime`과의 분담은
|
||||
`question.md` 0-B로.
|
||||
|
||||
### C. `hintValue` 폐기 제안 — 검토 결과 **사용자 지적이 맞음**
|
||||
|
||||
사용자 제기: "process 상 newv(hint) 처리가 단일 네스팅이면
|
||||
None -> AttributeKey 같은걸 탄다던가 하면 머리아픈데. 괜찮은게 진짜
|
||||
맞나 검토가 필요해."
|
||||
|
||||
**재현됨.** `[AttributeKey "foo"] = state`가 `5` ↔ `None`을 오갈 때
|
||||
체인 모양이 `StoreBind→NoneHandler→AttributeKey`(None) ↔
|
||||
`StoreBind→AttributeKey`(5)로 바뀌고, `5 → None` 전이에서 인덱스 2의
|
||||
`AttributeKeyHandler` retractor가 **`hintValue = None`** 을 받음.
|
||||
Attribute는 no-op이라 무해하지만 `TagHandler`였다면 `isTag(None)`이
|
||||
거짓이라 이름 전부를 `RemoveTag`함.
|
||||
|
||||
**진짜 문제는 깊이가 아니라 "힌트의 타입이 계약으로 정해져 있지
|
||||
않다"는 것**이었음 — 앞 라운드에서 문서화한 "깊이 2 이상에선 `nil`"은
|
||||
이 결함의 한 특수 케이스일 뿐이었다는 게 이번에 드러남. 힌트가 `None`/
|
||||
`State`/`Tween` 등 래퍼일 수 있어서, `Tag`의 `Contains` skip과
|
||||
`Ref`/`Slot`의 identity 비교가 **정확성은 유지한 채 조용히 꺼짐**(그래서
|
||||
지금까지 안 드러났음). `Slot`은 "가드 없으면 서브트리 전체 파괴 후
|
||||
재생성"이라 파급이 큼.
|
||||
|
||||
**사용자 제안 방향**: 철거 후 힌트와 재구축 대신, process를 쭉 진행하며
|
||||
각 자리에서 "이전 핸들러 vs 새 값에 매치될 핸들러"를 비교 — 다르면
|
||||
그 아래 전부 죽이고, 같으면 값을 계속 전파하며 자기 인덱스에 대해서만
|
||||
후처리. 전제 계약: **"`inst`에 실질적 처리를 가하는 건 항상 말단 핸들러,
|
||||
중간 노드는 언워랩만"**. 결론적으로 **"새 프로세싱으로 인한 retract와
|
||||
단순 retract는 다르다"**.
|
||||
|
||||
**검토 결과**:
|
||||
- 전제 계약은 **기존 핸들러 9개 전부에서 이미 성립**(표로 확인) — 새
|
||||
제약이 아니라 이미 있는 성질의 승격이라 채택 비용이 낮음.
|
||||
- 단, **"같은 핸들러면 유지"는 `StoreBind`에 그대로는 틀림** — 인덱스 2가
|
||||
`StoreBind`인 채 바깥이 새 inner State를 내놓으면 "같은 핸들러"지만
|
||||
옛 State에 구독돼 있어 갈아타야 함. → "유지"가 아니라 **"그 인덱스에
|
||||
`process`를 다시 호출(retractor는 안 부름)"** 이어야 함.
|
||||
- 그러면 핸들러가 이전 값을 알아야 하는데 → **힌트 대신 `oldValue`를
|
||||
넘기자**는 보완 제안. `chains`가 retractor 옆에 `(handler, value)`를
|
||||
같이 저장하면 됨. `oldValue`는 *그 핸들러가 직전에 실제로 매치한*
|
||||
값이라 **타입이 구조적으로 보장**되어 `None`/래퍼 오염이 원천 차단되고,
|
||||
방향도 맞아서(`hintValue`는 "다음", `oldValue`는 "이전")
|
||||
`RefLeafHandler`의 dedup용 `Relate`도 없앨 수 있음. `isX(v)` 방어
|
||||
가드 규칙 자체가 힌트의 타입 미보장을 메우던 임시방편이었음이 드러남.
|
||||
- **주의**: 같은 인덱스 재프로세싱을 허용하려면 점유 체크를 갈라야 하는데,
|
||||
**그 가드가 곧 Attribute 그룹의 소유권 충돌 감지**라 약해지지 않는지
|
||||
반드시 같이 확인해야 함(이번 감사에서 정확히 그 지점이 한 번 무너진
|
||||
전례가 있음).
|
||||
|
||||
**실행은 보류** — `base/` 4개 문서 의사코드 전면 재작성 규모라, 같은 날
|
||||
두 차례 급하게 쓴 의사코드에서 버그가 나온 전례를 감안해
|
||||
`research/dispatch-hint-to-oldvalue-plan.md`로 먼저 정리하고 확정 대기
|
||||
(`question.md` 0-A). 그때까지 `base/`의 현행 `hintValue` 서술이 유효.
|
||||
|
||||
### D. 평탄화 백로그 상세화 (사용자: "백로그에서 더 자세히 다루도록 업데이트만 하자")
|
||||
|
||||
`state:Flatten()`/`Flat()` — `Operator.*` 자유 함수가 아니라 **State의
|
||||
메소드**로 제공되어야 함(특정 노드를 따라가는 새 노드를 만드는 것이라
|
||||
`:Compute`/`:With`와 같은 층위). 대상은 `State<State<T> | T>`(섞인
|
||||
경우까지 흡수). **핵심 난점(사용자 지적): 반환 노드가 동적 의존성을
|
||||
가짐** — quad가 암묵적 자동 추적을 기각했고 "동적 `:With` 미지원"을
|
||||
2026-08-12 열여덟 번째 세션에 의도된 트레이드오프로 확정해뒀는데, 이
|
||||
도구는 그 유일한 정당한 예외를 요구함. 확정 전 답할 것: 동적 의존성을
|
||||
노드 내부에 가둬 바깥에선 평범한 `State<T>` 하나로 보이게 할 수 있는가,
|
||||
옛 구독 해제 타이밍과 `bindLifetime` 귀속, 그래서 순수 슈가가 아니라
|
||||
진짜 새 프리미티브인지(현재 판단은 후자). 착수 안 함.
|
||||
|
||||
## 네 번째 라운드 (같은 세션) — 사용자 반문으로 모델 확정, 내 제안 두 개 철회
|
||||
|
||||
### `dispose`는 "떼어낸 뒤 파괴"가 아니라 **"거부하고 error"**
|
||||
|
||||
> "dispose 는 정확히 Frame 이든, slot이든 어느 트리에 의해 살아 있는게
|
||||
> 요구된다면 Destroy 거부한다, 에러를 낸다고 보면 되겠네요. 실제로
|
||||
> 클리어 하거나 Destroy 해도 그냥 로블록스엔 에러 안 나는데, quad에선
|
||||
> 데이터 구조가 깨지는 일이니까요."
|
||||
|
||||
내가 쓴 "먼저 안전하게 떼어낸 뒤 파괴"보다 훨씬 단순하고 맞음 — 떼어내는
|
||||
건 `Set`(언마운트)의 몫이고 `dispose`는 그 뒤에 부르는 것. 이걸로
|
||||
"`Set` 전에 직접 `Destroy()`"가 UB에서 **명확한 에러**로 바뀜.
|
||||
|
||||
### Slot의 "해제 짝"은 애초에 필요 없었음
|
||||
|
||||
> "옛 오너가 setLength/setOffsetSource 를 그냥 실행해도 된다는 생각.
|
||||
> retract에서 hint 를 보고 Slot이 아니면 그냥 setLength(...0...)
|
||||
> setOffsetSource(...None...) 될 수 있어요."
|
||||
|
||||
맞음 — 이미 확정된 "마운트 안 하는 위치는 `0`/`None` 등록" 관용구 그대로라
|
||||
**해제 = 0/`None`으로 재등록**이고 새 API가 필요 없음. 앞 라운드에서
|
||||
"이게 언마운트 전환의 실제 작업량"이라고 꼽은 판단은 과했음.
|
||||
`state<state<Frame>>`류 offset 밀림은 `state<state<Tag>>`와 같은 범주로
|
||||
**"그냥 확인된 것"으로 수용**(평탄화 도구가 처리, 케이스 드묾).
|
||||
|
||||
### 디스패치 모델 확정 — 내 반론 두 개가 다 틀렸음
|
||||
|
||||
**(1) "같은 핸들러면 `StoreBind`가 구독을 못 갈아탄다"** — 내가 사용자
|
||||
제안을 "같으면 **유지**"로 잘못 읽은 데서 나온 반론이었음. 실제 제안은
|
||||
"같으면 **내가 처리**"(= 그 자리 클로저 호출 → 자기 `process` 재호출)라
|
||||
옛 구독 해제와 새 구독이 그 안에서 끝남. 사용자: "다만 뭐가 문제인지
|
||||
모르겠어요" — 문제 없었음.
|
||||
|
||||
**(2) `oldValue`를 따로 넘기자는 보완안** — 사용자: "이전 값인 oldValue 는
|
||||
처음부터 클로저라 이미 본인이 알지 않아요?" 맞음. 클로저는 자기 `v`를
|
||||
캡처하고 힌트로 새 값을 받으므로 old/new를 이미 둘 다 갖고 있음. 진짜
|
||||
문제였던 힌트의 **타입 보장**은 "핸들러 비교를 클로저 호출 *앞*에 둔다"는
|
||||
것만으로 해결됨(같은 핸들러일 때만 값이 넘어가고, 그 값은 정의상
|
||||
`isHandlable`을 만족) — `chains`에 추가할 건 비교용 `handler` 하나뿐.
|
||||
|
||||
**부수 발견**: 이 모델이면 **깊은 체인의 힌트 유실도 같이 사라짐.** 힌트를
|
||||
위에서 아래로 전파하는 게 아니라 각 레벨이 자기 재프로세스에서 자기
|
||||
힌트를 받으므로, `State<State<Tag>>`에서 바깥이 새 inner를 내놔도 index 3의
|
||||
TagHandler가 **진짜 `Tag`를 힌트로 받아** `Contains` skip이 살아남. 앞
|
||||
라운드에 "구조상 불가피"라고 적었던 건 철거-선행 모델에서만 참이었음.
|
||||
`HandlerChanged` 마커도 불필요(핸들러가 바뀐 건 retractor가 `nil` 힌트로
|
||||
불린다는 사실로 이미 표현됨).
|
||||
|
||||
### 유일하게 이견 — Attribute 소유권은 "자연히" 처리되지 않음
|
||||
|
||||
사용자는 "이 방식으로 가면 자연히 Attribute 의 소유권 충돌은 처리되네요"
|
||||
라고 봤으나, 추적 결과 **그렇지 않음**: 그룹 A가 잡아둔 이름에 그룹 B가
|
||||
들어오면 인덱스 1의 핸들러가 양쪽 다 `StoreBind`라 "같은 핸들러"로 판정돼
|
||||
조용히 갈아타고, 나중에 A의 클로저가 B의 바인딩을 대신 철거함(교차 오염) —
|
||||
이번 감사에서 고친 바로 그 증상이 되돌아옴. 다만 사용자의 나머지 절반
|
||||
("오류 처리가 필요한 곳이면 직접 처리하면 되니까요")은 맞고, 그 '직접
|
||||
처리'를 뭘로 할지가 유일한 결정 사항. **권고: 이름별 claimant `Relate`를
|
||||
Attribute 쪽에 국소적으로 둠** — 네 번째 세션에 `owners`로 만들었다
|
||||
기각됐던 그것인데, **당시 기각 사유("소유권 반납이 `v==nil` 분기에만 있어
|
||||
안 지워짐")가 새 모델에선 구조적으로 소멸**(클로저가 항상 불리고 거기서
|
||||
반납). 문서: `research/dispatch-redispatch-diff-plan.md`(파일명도
|
||||
`dispatch-hint-to-oldvalue-plan.md`에서 바꿈, `oldValue`가 철회됐으므로).
|
||||
|
||||
## 다섯 번째 라운드 (같은 세션, 마무리) — 해제 순서 주의 + Attribute 이관
|
||||
|
||||
### `setOffsetSource(None)` → `setLength(0)` 순서 고정 (사용자 지적)
|
||||
|
||||
> "setLength 먼저 수행하고 setOffsetSource 수행하기 보단 setOffsetSource 를
|
||||
> 먼저 날려야할듯 합니다 - 물론 별 상관 없어요. 자기 자신의 length 가
|
||||
> 줄어든다는 의미는, 자기 자신 offset은 여전하다는건데, 그래서 업데이트
|
||||
> 될 일이 없긴합니다. 다만, 여전히 setLength 로 인해 다시 slot 들의
|
||||
> offset들이 재계산 될 때 invalid 한 offset source 자체가 있다는것 부터
|
||||
> 위험합니다. 방어적으로 처리하세요."
|
||||
|
||||
`setLength`는 끝에서 `recompute`를 돌리고 `recompute`는 `sourceList`를
|
||||
순회하며 `offset:Set(sum)`을 호출함 — 그래서 **`setLength(0)`을 먼저
|
||||
부르면 그 시점에 해제 중인 자리의 `sourceList[i]`엔 아직 옛 Slot의 offset
|
||||
`Source`가 남아 있어**, 지금 막 떼어내는 서브트리의 Source에 `:Set()`이
|
||||
날아가고 그걸 구독하던 (곧 없어질) 자식들의 `LayoutOrder` 계산이 헛되이
|
||||
캐스케이드됨. `setOffsetSource(None)`을 먼저 하면 `recompute`의
|
||||
`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림.
|
||||
|
||||
값이 틀려지는 문제는 아님(자기 length가 줄어도 **자기 앞 형제들의
|
||||
누적합은 그대로**라 자기 offset은 갱신될 일 자체가 없음) — 위험한 건
|
||||
"invalid한 Source가 순회 대상에 남아있다"는 것 자체. 방어적으로 순서를
|
||||
계약으로 고정하고, 추가 조치 둘을 같이 넣음:
|
||||
- 해제 시 `slot.Offset = nil`(이 문서의 "마운트 전엔 `nil`" 규칙과 짝),
|
||||
- `recompute`가 `sourceList[i]`의 `nil`도 `None`과 똑같이 skip(전이
|
||||
구간에서 관측돼도 크래시 대신 넘어가도록 — 등록 쪽의 "반드시 `None`"
|
||||
의무는 그대로).
|
||||
|
||||
`base/bind-system-plan.md`(Length/Offset 절, `recompute` 의사코드)와
|
||||
`base/slot-plan.md`(언마운트 절) 양쪽에 반영.
|
||||
|
||||
### Attribute 소유권 — 다음 세션 심층 분석으로 명시 이관
|
||||
|
||||
> "Attribute 소유권은 아마 이전 결정을 다시 가져오는게 맞아보이긴 하네요.
|
||||
> 막 깊게 Key -> Group 필요한것 같지는 않고, 본인 retract 처리를 수행할 때
|
||||
> 무언가 하면 될듯 한데. 이 부분은 나중에 제가 물리적으로 스케치 해보며
|
||||
> 심층 분석해보겠습니다. 당장은 이 세션 중 나온 내용, 지식이 누락 없게
|
||||
> stale 없게 처리해주시고. ... 이미 세션이 길고, 이 부분을 더 깊게 파기엔
|
||||
> 다른 부분을 누락할 위험이 있어요."
|
||||
|
||||
방향은 이미 잡혀 있음(이름별 claimant `Relate` 부활, 단방향이면 충분,
|
||||
반납은 클로저 안에서) — 다만 확정은 사용자가 직접 스케치한 뒤로.
|
||||
`question.md`에 **0-Z(최우선)** 로 분리하고, CLAUDE.md "지금 할 일"에
|
||||
0번 항목으로 올려 핸드오버.
|
||||
|
||||
**핸드오버에서 가장 중요한 한 가지**: **`base/`의 현행 `hintValue` 서술은
|
||||
아직 옛 모델(철거 선행)이고, 새 모델은 `research/
|
||||
dispatch-redispatch-diff-plan.md`에만 있음** — base만 읽고 구현하면 옛
|
||||
모델로 짜게 됨. 0-Z가 정해지면 그 문서 6절의 파일별 목록대로 4개 base
|
||||
문서를 한 번에 옮길 것. 반대로 **Slot의 언마운트/`dispose`/해제 순서는
|
||||
이미 base에 확정 반영됨**(재디스패치 모델과 독립적인 결정이라 먼저 들어감)
|
||||
— 이 비대칭을 헷갈리지 말 것.
|
||||
|
||||
71
CLAUDE.md
71
CLAUDE.md
|
|
@ -110,6 +110,25 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
|
||||
## 지금 할 일 (우선순위순)
|
||||
|
||||
0. **⭐ 최우선 — `.claude/question.md` 0-Z(Attribute 이름 소유권) 결정.**
|
||||
2026-08-13 여섯 번째 세션에 `Dispatch` 재디스패치 모델이 "하강 diff"로
|
||||
다시 정리되면서(`research/dispatch-redispatch-diff-plan.md`), **그
|
||||
모델에서 유일하게 안 풀린 것이 Attribute 그룹의 이름 소유권 충돌
|
||||
감지**임. 사용자가 "이전 결정(이름별 claimant `Relate`)을 다시 가져오는
|
||||
게 맞아 보이나, 다음 세션에 직접 물리적으로 스케치하며 심층 분석"으로
|
||||
명시 이관 — 그전까지 아래 항목들보다 우선.
|
||||
**핸드오버 시 반드시 알아야 할 것**:
|
||||
- **`base/`의 현행 `hintValue` 서술은 아직 옛 모델(철거 선행)이다.**
|
||||
새 모델은 `research/dispatch-redispatch-diff-plan.md`에만 있음 —
|
||||
base만 읽고 구현하면 옛 모델로 짜게 됨. 0-Z가 정해지면 그 문서 6절의
|
||||
파일별 반영 목록대로 `bind-system-plan.md`/`tag-plan.md`/
|
||||
`slot-plan.md`/`attribute-plan.md`를 **한 번에** 옮길 것.
|
||||
- 반대로 **`base/slot-plan.md`의 "언마운트/`dispose`/해제 순서"는 이미
|
||||
확정 반영됨**(재디스패치 모델과 독립적인 결정이라 먼저 들어감).
|
||||
- 이 세션에서 같은 날 두 차례 급하게 쓴 의사코드가 각각 버그를 냈다는
|
||||
사실 자체가 교훈 — 0-Z 반영도 서두르지 말고 손 트레이싱을 거칠 것
|
||||
(`bind-system-plan.md` "Handler 작성 체크리스트" 절이 그 산물).
|
||||
|
||||
1. **구현 시작 — 루트 `ROADMAP.md`의 M0부터.** 설계 단계는 2026-08-04 로드맵
|
||||
인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은
|
||||
2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부
|
||||
|
|
@ -131,8 +150,12 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
대신 `retractFrom`+`process`가 반환하는 클로저) — 기존 `luau-test/04`
|
||||
(다단 체인 스트레스 테스트)는 옛 모델(핸들러 identity 기반 `chains`)
|
||||
전제로 쓰여 있어서, 돌려보기 전에 새 모델에 맞춰 먼저 다시 써야 함
|
||||
(`session/2026-08-13-05-dispatch-index-based-redesign.md` "남는 것"
|
||||
참고) — 아직 안 함, 다음 세션 우선 항목.
|
||||
— **[2026-08-13 여섯 번째 세션에 완료]** `04-dispatch-chain-
|
||||
retractFrom.luau`로 전면 재작성됨(인덱스 기반 3단 체인 + 같은 세션
|
||||
감사가 발견한 `chains:SetStrong` 순서 버그를 재현하는 음성 대조군
|
||||
포함). 대신 `luau-test/19`의 B/C 섹션이 폐기된 설계(`rawNew`+
|
||||
`owners`, 3분기 `claimOwner`)를 검증 중인 게 새로 드러나 재작성
|
||||
대기 — 무엇을 어떻게 고쳐야 하는지는 그 파일 헤더 배너에 적어둠.
|
||||
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
|
||||
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
|
||||
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는
|
||||
|
|
@ -849,3 +872,47 @@ handoff용 저장이 불필요해짐 — `Relate`는 여러 위치/사이클을
|
|||
`attribute-plan.md`/`slot-plan.md`/`architecture.md`/`store-semantics.md`/
|
||||
`modifier-plan.md` 전부 반영, `archive/checkpoint-handler-pattern-reversed.md`
|
||||
신설.
|
||||
|
||||
**2026-08-13 여섯 번째 세션 — c33ae04 커밋 전체 감사(버그 4건), Slot
|
||||
언마운트 전환, 재디스패치 모델 재설계**
|
||||
(`session/2026-08-13-06-commit-audit-dispatch-redesign-bugs.md`)
|
||||
사용자 지시로 직전 커밋을 메인 컨텍스트에서 직접 정독 — 인덱스 재설계
|
||||
**방향은 옳지만 새로 쓴 의사코드가 손 트레이싱을 안 거친 채 커밋**됐음이
|
||||
드러나 실제 버그 4건 발견·수정: (1) `Dispatch.process`의 `chains:SetStrong`이
|
||||
`h.process` 뒤에 있어 최초 마운트에서 하위 retractor가 통째로 유실,
|
||||
(2) Attribute 그룹이 `process`에서 `retractFrom`을 선행 호출해 점유
|
||||
체크(=소유권 충돌 감지)가 전혀 작동 안 함, (3) `SlotHandler`가 claim
|
||||
실패에도 파괴적 클로저를 반환해 `Frame{slot,slot}`에서 이중 파괴,
|
||||
(4) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup 무력화.
|
||||
Slot 소유권은 nested=엄격 `claimOwner`/top-level=`claimOwnerAt(inst,k)`로
|
||||
분리하고 `rawRemove`의 `releaseOwner` 누락·`destroySlotTree`의 GC 타이밍
|
||||
의존 오류도 수정. `ROADMAP.md`가 2026-08-08 이전 모델로 남아있던 것, base
|
||||
내 "3종 vs 4종 계약" 모순, `luau-test/04`가 없어진 가드를 검증하던 것도
|
||||
정리(`04`는 인덱스 모델 + 버그 (1) 재현 음성 대조군으로 재작성).
|
||||
재발 방지로 `bind-system-plan.md`에 "Handler 작성 체크리스트"(7항목),
|
||||
`relate-plan.md`에 "언제 `Relate`를 쓰고 언제 쓰면 안 되는가" 신설.
|
||||
|
||||
**이어진 사용자 설계 결정(같은 세션, 최종 상태만)**:
|
||||
- **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일,
|
||||
비파괴 추출은 `Splice`로 이미 지원) — **포탈이 별도 기능이 아니라 이
|
||||
결정의 귀결이 됨**. 해제는 `setOffsetSource(None)` → `setLength(0)`
|
||||
**순서 고정**(반대면 죽는 중인 서브트리의 offset `Source`에 헛된 `:Set()`이
|
||||
날아감) + `recompute`의 `nil` 관대 처리/`slot.Offset = nil` 방어.
|
||||
별도 unregister API는 불필요. `base/slot-plan.md`에 **확정 반영 완료**.
|
||||
- **`dispose(value)` 신설** — "트리가 아직 살아있길 요구하면 파괴를 거부하고
|
||||
error"(엔진은 조용히 넘어가지만 quad 자료구조가 깨지므로). 시그니처/범위는
|
||||
`question.md` 0-B.
|
||||
- **재디스패치를 "하강 diff"로 재설계** — 래핑 핸들러의 `retractFrom` 선행
|
||||
호출을 폐기하고 `Dispatch.process`가 **핸들러를 먼저 비교**(같으면 그 자리
|
||||
클로저에 새 값 넘기고 자기 process 재호출, 다르면 아래 전량 철거). 계기는
|
||||
힌트가 `None`/래퍼로 오염돼 깜빡임 방지가 조용히 꺼지는 결함(사용자 제기).
|
||||
이 모델이면 **힌트 타입이 구조적으로 보장되고 깊은 체인 힌트 유실까지
|
||||
사라짐**. 내가 낸 반론("StoreBind 구독 갈아타기")과 보완안(`oldValue` 전달)은
|
||||
둘 다 사용자 지적으로 철회 — 클로저가 이미 old를 캡처하고 있음.
|
||||
**아직 `research/dispatch-redispatch-diff-plan.md`에만 있음, base 미반영.**
|
||||
- **평탄화**(`state:Flatten()`)는 백로그 상세화만 — 반환 노드의 **동적
|
||||
의존성**이 최대 쟁점(`research/operator-sugar-plan.md`).
|
||||
- **남은 결정 하나: Attribute 이름 소유권** — 새 모델에선 양쪽 다
|
||||
`StoreBind`라 "같은 핸들러"로 판정돼 조용히 갈아탐. 사용자가 "이전
|
||||
결정(claimant `Relate`)을 다시 가져오는 게 맞아 보이나 다음 세션에 직접
|
||||
스케치하며 심층 분석"으로 이관 — `question.md` **0-Z(최우선)**.
|
||||
|
|
|
|||
109
ROADMAP.md
109
ROADMAP.md
|
|
@ -24,8 +24,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`base/store-semantics.md` "Source가 State를 만족함" 절 — `State<T>`가
|
||||
`Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는
|
||||
피할 수 있어 보이나 실제 검증 전엔 확정 아님)
|
||||
- [ ] `process`/`retract` 재귀 재-process 디스패치를 실제로 짜보기(store-bind
|
||||
핸들러 하나 + `isHandlable` 우선순위 스캔 포함)
|
||||
- [ ] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로
|
||||
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함)
|
||||
- [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제
|
||||
Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass +
|
||||
일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증
|
||||
|
|
@ -64,20 +64,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
## M2 — 디스패치 엔진
|
||||
|
||||
- [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수
|
||||
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v)`(오케
|
||||
스트레이터: getHandler → 이전 담당자와 다르면 그 `retract` → 새
|
||||
핸들러의 `.process`) / `Dispatch.addHandler(handler)`(레지스트리
|
||||
등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
|
||||
flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 `process` 호출 —
|
||||
`bind-system-plan.md`의 `None` 센티널 절, 2026-08-07 여덟 번째 세션에
|
||||
네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은
|
||||
`Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서
|
||||
갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀
|
||||
재디스패치를 쓰는 케이스(`StoreBind`, `NoneHandler`)에서 매
|
||||
사이클 불필요한 `retract`가 반복 호출될 위험)
|
||||
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
|
||||
(오케스트레이터: 그 인덱스 점유 여부 체크 → getHandler → 매치된
|
||||
핸들러의 `.process`를 불러 **그 반환값(retractor 클로저)을
|
||||
`chains`의 그 인덱스에 저장**) / `Dispatch.retractFrom(inst,k,index,v)`
|
||||
(아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록,
|
||||
quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
|
||||
flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에
|
||||
`Dispatch.process(inst,k,v,1)` 호출 — `bind-system-plan.md`의 `None`
|
||||
센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정).
|
||||
**[2026-08-13 다섯 번째 세션 전면 재설계]** "이전 담당자와 다르면
|
||||
그 `retract`"라는 옛 diff 모델은 폐기 — 정리 책임은 전적으로
|
||||
재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch 전에
|
||||
스스로 `retractFrom`을 부르는 쪽에 있고, `Dispatch.process`는
|
||||
diff를 하지 않음
|
||||
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
|
||||
`process`/`retract` — `isHandlable`도 `inst`를 받도록 확정, 2026-08-07
|
||||
여덟 번째 세션 정정)
|
||||
`process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도
|
||||
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
|
||||
`process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
|
||||
- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/
|
||||
`Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
|
||||
`isAttributeKey`/`isAttribute`/`isTween`/`isBlocker`/`isSource`/
|
||||
|
|
@ -133,10 +137,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
|
||||
(2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset"
|
||||
절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소)
|
||||
- [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서
|
||||
걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러
|
||||
교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약"
|
||||
절, 2026-08-08 세션)
|
||||
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
|
||||
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
|
||||
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
|
||||
호출, `base/bind-system-plan.md` "핸들러 계약" 절, 2026-08-08 세션
|
||||
/ **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process`
|
||||
반환값으로 합쳐지며 대상만 바뀜**)
|
||||
- [ ] 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정,
|
||||
`base/bind-system-plan.md` "우선순위 동률/매치 실패 처리" 절) —
|
||||
`HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW` 등 목적별 우선순위 상수,
|
||||
|
|
@ -149,13 +155,25 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) —
|
||||
quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/
|
||||
bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절)
|
||||
- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={handler,handler,...}
|
||||
(strong 순서 배열)}}`) + `Dispatch.retractUnder(inst,k,keep,v)` —
|
||||
재귀 재-dispatch(StoreBind/NoneHandler)의 retract를 다단
|
||||
체인까지 정확히 전파(2026-08-08 세 번째 세션, `base/
|
||||
bind-system-plan.md` "Dispatch 체인" 절 — `pre-implementation-audit.md`
|
||||
1-2번 "이전 핸들러 추적" 항목 해소). `Dispatch.process`가 매치될
|
||||
때마다 체인에 push하는 것도 이 항목에 포함
|
||||
- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]=retractor}}}` —
|
||||
**핸들러 배열이 아니라 재귀 깊이 인덱스→retractor 클로저 맵**) +
|
||||
`Dispatch.retractFrom(inst,k,index,v)` — 재귀 재-dispatch(StoreBind/
|
||||
NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08 세 번째
|
||||
세션 신설, **2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계** —
|
||||
`base/bind-system-plan.md` "Dispatch 체인" 절,
|
||||
`pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소).
|
||||
`Dispatch.process(inst,k,v,index)`가 반환 클로저를 그 인덱스에
|
||||
저장하는 것도 이 항목에 포함. **구현 시 반드시 지킬 것**:
|
||||
- `chains:SetStrong(inst,k,list)`는 `handler.process` 호출 **전에** —
|
||||
뒤에 두면 재귀 위임이 자기 테이블을 만들었다가 바깥이 덮어써
|
||||
하위 retractor가 통째로 유실됨(2026-08-13 감사에서 잡힌 버그)
|
||||
- `handler.process` 호출 전에 그 인덱스에 no-op 점유 마커를 박아
|
||||
`list`를 구멍 없는 시퀀스로 유지(hole 있는 테이블의 `#`는 Lua가
|
||||
보장 안 함) + 같은 index 재진입도 가드에 걸리게
|
||||
- 점유 체크는 `getHandler`/`handler.process`보다 **먼저**(핸들러
|
||||
부작용 낭비 없음)
|
||||
- 다른 키로 위임할 땐 항상 `index=1`, 같은 키 재귀는 `index+1`;
|
||||
`Dispatch.drive`의 진입도 항상 `1`
|
||||
- [ ] mock 대상 테스트
|
||||
|
||||
## M3 — Store/State/Source
|
||||
|
|
@ -213,11 +231,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
## M4 — 첫 end-to-end 반응형 업데이트
|
||||
|
||||
- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — 재-dispatch
|
||||
전 `Dispatch.retractUnder(inst,k,self,realv)` 호출 필수, `base/
|
||||
bind-system-plan.md` "Dispatch 체인" 절)
|
||||
전 `Dispatch.retractFrom(inst,k,index+1,realv)` 호출 필수, 그 다음
|
||||
`Dispatch.process(inst,k,realv,index+1)`. `base/bind-system-plan.md`
|
||||
"Dispatch 체인" 절)
|
||||
- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" +
|
||||
"이전 값이 다른 타입이면 이전 핸들러의 `retract`가 정확히 불린다"
|
||||
확인
|
||||
"이전 값이 다른 타입이면 이전 `process`가 반환했던 retractor 클로저가
|
||||
정확히 불린다" 확인 + **`State<State<T>>`(값이 또 State/Source)가
|
||||
인덱스 N/N+1로 안 겹치고 정상 동작하는지**(2026-08-13 다섯 번째
|
||||
세션에 UB→정상 지원으로 재정정) + **최초 마운트 직후 첫 재발행에서
|
||||
인덱스 2의 retractor가 실제로 불리는지**(위 M2의 `SetStrong` 순서
|
||||
버그가 정확히 여기서 증상으로 나타남)
|
||||
|
||||
## M5 — quad-roblox 최소 프로바이더
|
||||
|
||||
|
|
@ -471,20 +494,26 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1,
|
||||
store2, ...)`/`Merged`, `Tag`와 동형 array-part 값 객체,
|
||||
`base/attribute-plan.md`)
|
||||
- [ ] `Handlers/Attribute.luau`(quad-roblox — 그룹 process/retract, 이전
|
||||
이름 집합과의 diff만 자체 로직, 실제 `SetAttribute`/store-bind
|
||||
구독은 메모이즈된 `AttributeKey(name)`로 `Dispatch.process`/
|
||||
`retractUnder`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음),
|
||||
`base/attribute-plan.md`)
|
||||
- [ ] `Handlers/Attribute.luau`(quad-roblox — 그룹 `process`가 이름마다
|
||||
공개 `AttributeKey(name)`로 `Dispatch.process(inst,key,source,1)`만
|
||||
부르고, 반환 클로저가 **자기가 등록한 이름 전부**에
|
||||
`Dispatch.retractFrom(inst,key,1,nil)`. 실제 `SetAttribute`/store-bind
|
||||
구독은 전부 단일 키 경로 재사용(중복 구현 없음).
|
||||
**`process` 안에서 `retractFrom`을 먼저 부르면 안 됨** — 인덱스 1이
|
||||
무조건 비워져 소유권 충돌 점유 체크가 무력화됨(2026-08-13 감사),
|
||||
`base/attribute-plan.md` "메커니즘" 절)
|
||||
- [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/
|
||||
`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md`
|
||||
— 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트
|
||||
모델은 `archive/tag-hash-key-model-reversed.md`)
|
||||
- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` process/retract
|
||||
글루만, `isHandlable`은 `isTag(v)`. `retract`는 이제 의미 있음(값이
|
||||
Tag가 아니게 되면 전체 삭제), 같은 Tag끼리 바뀌는 diff는 `process`가
|
||||
자기 `Relate` 저장분과 비교해서 처리 — 전체 삭제 후 재생성 금지(랙
|
||||
유발), `base/tag-plan.md` 참고)
|
||||
- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` 글루만,
|
||||
`isHandlable`은 `isTag(v)`. **`AddTag`는 온전히 `process`, `RemoveTag`는
|
||||
온전히 반환 클로저** — 이름별 홀더 집합(`tagNameMap`, 위치 `k` 기준
|
||||
참조 카운트)이 비었을 때만 실제 `RemoveTag`, 그마저도 `hintValue`가
|
||||
그 이름을 `Contains`하면 skip해 깜빡임 방지(전체 삭제 후 재생성
|
||||
금지). `process` 쪽 별도 diff 없음, `kTagMap`도 불필요(클로저가 `v`를
|
||||
직접 캡처) — 2026-08-12 열한 번째 / 2026-08-13 네·다섯 번째 세션,
|
||||
`base/tag-plan.md` "메커니즘" 절)
|
||||
|
||||
## M11 — Tween
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue