fix(lifecycle): canExecute/unbindLifetime을 value 1-인자로 정정, Subscribed 오염 제거
canExecute(inst,value) 2-인자 시그니처를 폐기하고 canExecute(value)로 정정. 2-인자는 증상이었고 원인은 2026-08-08 세션이 .Subscribed에 "leaf 바인딩 생존"이라는 두 번째 의미를 겹쳐 얹은 것 — .Subscribed는 전역 :Subscribe() 전용 필드이고 bindLifetime과 무관함. bindLifetime이 바인딩 시점에 inst의 gcconn 참조를 value 쪽 Relate로 복사해두면 생존을 value 하나로 물을 수 있음. - canBound(handle) 폐기 → canExecute(value)로 통합(이중 바인딩 게이트 겸함) - unbindLifetime도 1-인자로 축소 — 호출부가 _mountedInst를 되짚을 필요 없어져 "홀더가 갈아치워지면 해제가 빗나가는" 잠재 버그 클래스 소멸(slot-plan 5곳) - gcconn/gchold를 lazy 생성에서 Instance 생성 시점으로 전환, 클로저가 inst까지 캡처 — Instance userdata 포인터 동일성은 inst-키 Relate 전체의 전제였음 (relate-plan에 "전제" 절 + "안전히 유지되면 항상 SetWeak" 일반 규칙 신설) - canExecute의 실제 호출부를 State 전파 루프로 명시(구독자 weak + 발화마다 게이팅) — 이게 코드로 한 번도 안 적힌 게 오류가 여섯 세션 살아남은 이유 역전 원문/오염 경로/교훈은 archive/canexecute-inst-arg-reversed.md. luau-test/10은 폐기된 모델을 검증 중이라 rewrite-required/로 이동. 부수: 3~4차 세션이 남겨둔 CLAUDE.md 세션 히스토리 항목과 4차 세션 로그 파일도 미커밋 상태여서 같이 실림. doc-check ERROR 1건(CLAUDE.md:1156 → session/2026-08-14-03-lifecycle-hooks-plan.md)은 그 파일이 디스크 어디에도 없어서 남음 — 3차 세션 쪽에서 채워야 함. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c5ea3aa597
commit
af513aeb84
22 changed files with 1098 additions and 239 deletions
File diff suppressed because one or more lines are too long
102
.claude/archive/canexecute-inst-arg-reversed.md
Normal file
102
.claude/archive/canexecute-inst-arg-reversed.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# [역전됨] `canExecute(inst, value)` 2-인자 + `bindLifetime`이 `.Subscribed`를 세팅 — 둘 다 오염, `canExecute(value)` 1-인자로 정정
|
||||
|
||||
**역전 일시**: 2026-08-14 (다섯 번째 세션). **원 확정 일시**: 2026-08-08
|
||||
(다섯 번째 세션, "재정정"이라는 이름으로 들어옴) ~ 2026-08-09 (여섯 번째
|
||||
세션, `canBound`가 이 전제 위에 세워짐).
|
||||
**현재 유효한 설계**: `base/lifecycle-pattern.md`의
|
||||
"`bindLifetime`/`canExecute`/`unbindLifetime`" 절이 최종 소스.
|
||||
|
||||
## 역전된 사례 — 원래 무엇을 확정했었나
|
||||
|
||||
```lua
|
||||
bindLifetime(inst: any, value: any): ()
|
||||
unbindLifetime(inst: any, value: any): ()
|
||||
canExecute(inst: any, value: any): boolean
|
||||
```
|
||||
|
||||
그리고 `bindLifetime` 구현 스케치가 이랬음:
|
||||
|
||||
```lua
|
||||
function bindLifetime(inst, value)
|
||||
...
|
||||
gchold[value] = true
|
||||
if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용
|
||||
end
|
||||
|
||||
function canExecute(inst, value)
|
||||
if (isObserver(value) or isEffect(value)) and not value.Subscribed then
|
||||
return false
|
||||
end
|
||||
local gcconn = relate:GetStrong(inst, GCCONN)
|
||||
return gcconn ~= nil and gcconn.Connected
|
||||
end
|
||||
```
|
||||
|
||||
당시 명시된 근거(2026-08-08 세션 원문): *"Observer 자신의 바인딩
|
||||
생존(`Subscribed`)과 `inst` 자체 생존(gcconn)은 **독립적인 두 조건**이라
|
||||
하나의 opaque `handle`로 뭉치면 'inst는 살아있지만 이 Observer는 이미
|
||||
`:Unsubscribe()`됨' 케이스를 못 구별함."*
|
||||
|
||||
여기에 2026-08-09 여섯 번째 세션이 한 겹 더 얹어, `bind-system-plan.md`의
|
||||
`canBound(handle)` 절에 **"이 내부 플래그는 새 필드가 아니라 `canExecute`가
|
||||
이미 보는 `.Subscribed` 필드 그 자체"**라고 못박고, `bindLifetime`/
|
||||
`unbindLifetime`이 이 필드를 세팅/해제하는 것으로 확정했음.
|
||||
|
||||
## 왜 틀렸나
|
||||
|
||||
**`.Subscribed`는 전역 `:Subscribe()` 경로 전용 필드이고, `bindLifetime`과는
|
||||
일절 이해관계가 없다**(사용자가 여러 차례 명시해온 내용). 위 설계는 이
|
||||
필드에 "leaf 바인딩도 살아있음"이라는 두 번째 의미를 억지로 겹쳐 얹었고,
|
||||
그 순간 **leaf 경로의 생존을 `value`에게 물을 방법이 사라져서** 남은 유일한
|
||||
경로가 "`inst`의 gcconn을 조회한다"가 됐음 — 2-인자 시그니처는 그 오염의
|
||||
*증상*이지 원인이 아니었음.
|
||||
|
||||
정확한 분해는 이것:
|
||||
|
||||
| 묻는 것 | 근거 | `value`만으로 가능? |
|
||||
| --- | --- | --- |
|
||||
| 전역으로 등록됐나 | `value.Subscribed` 필드 | O |
|
||||
| 묶인 `inst`가 살아있나 | `bindLifetime`이 `value` 쪽 릴레이션에 **복사해둔 gcconn** | O |
|
||||
|
||||
즉 두 조건이 "독립적"이라는 관찰 자체는 맞았지만, 그로부터 "`inst`를 인자로
|
||||
받아야 한다"는 결론이 안 나옴 — `bindLifetime`이 바인딩 시점에 gcconn 참조를
|
||||
`value` 쪽으로 복사해두면 둘 다 `value` 하나로 물을 수 있음. 실제로
|
||||
2026-08-07 시점의 더 오래된 초안(`bind-system-plan.md`의 `:Subscribe()` 절)에
|
||||
이미 올바른 모양이 스케치돼 있었음:
|
||||
|
||||
```lua
|
||||
if self.Subscribed then return true end
|
||||
if self.Connection then return self.Connection.Connected end
|
||||
```
|
||||
|
||||
2026-08-08의 "재정정"은 이 초안을 개선한 게 아니라 **되돌린 것**이었음.
|
||||
|
||||
## 놓친 신호 — 호출부가 코드로 한 번도 안 나왔다
|
||||
|
||||
이 오류가 여섯 세션 넘게 살아남은 이유는 **`canExecute`의 실제 호출부가
|
||||
어느 문서에도 코드로 등장한 적이 없기 때문**. `bind-system-plan.md`/
|
||||
`store-semantics.md`/`slot-plan.md`는 전부 "발화 시 `canExecute`로 게이팅됨"
|
||||
같은 **서술만** 하고 넘어갔고, `dispatch-core-plan.md`는 아예
|
||||
"핸들러가 직접 `canExecute`를 재구현할 필요 없음 — Observer가 이미 자기
|
||||
`Subscribed` 상태로 게이팅됨"이라고 적어 호출부를 없는 것처럼 만들었음.
|
||||
|
||||
실제 호출부는 **State의 전파 루프**인데, 거기엔 `inst`가 없고 있어서도 안 됨
|
||||
(State는 자기가 어느 Instance에 걸렸는지 모르는 게 정상 — 여러 곳에 걸릴 수
|
||||
있음). 즉 2-인자 시그니처는 **진짜 호출부에서 호출 자체가 불가능**했고,
|
||||
아무도 그 코드를 써보지 않아서 드러나지 않았을 뿐임.
|
||||
|
||||
**일반 교훈**: 계약(시그니처)을 정할 때 **호출부를 최소 하나는 의사코드로
|
||||
같이 적어둘 것.** "어디선가 게이팅됨"이라는 서술은 검증이 안 되는 문장이고,
|
||||
실제로 이 코퍼스에서 여섯 세션을 살아남았음. `.claude/tools/doc-check.py`가
|
||||
잡을 수 있는 종류가 아니므로(문서 참조는 전부 정상이었음) 사람/에이전트
|
||||
감사 체크리스트 쪽에 남김.
|
||||
|
||||
## 같이 폐기된 것
|
||||
|
||||
- **`canBound(handle)`** — 이 오염된 `.Subscribed` 재사용 위에 세워진
|
||||
predicate라 정의 자체가 성립 안 함. `canExecute(value)` 하나로 통합
|
||||
(`base/lifecycle-pattern.md`의 "`canBound` 폐기" 절).
|
||||
- **gcconn/gchold의 lazy 생성** — `bindLifetime` 첫 호출에서 만들던 것을
|
||||
**Instance 생성 시점**으로 올림. 이유는 이 역전과 별개(Instance userdata
|
||||
포인터 동일성 — `inst`-키 `Relate` 전체의 전제), 같은 세션에 확정돼 같은
|
||||
절에 반영됨.
|
||||
|
|
@ -1,10 +1,21 @@
|
|||
# gcconn 트릭 — 부분 실측 검증 결과
|
||||
|
||||
**상태**: 부분 확인(2026-08-13). `10-roblox-studio-checks.server.luau`(`luau-test/not-run/`)의
|
||||
공식 스크립트가 아니라 사용자가 별도로 작성한 저수준 검증 스크립트로
|
||||
확인됨 — `bindLifetime`/`canBound`/`unbindLifetime` 자체의 이중 바인딩
|
||||
로직(A-1/A-2)과 Part B/C는 여전히 미검증. **아래 "아직 확인 안 된 것"이
|
||||
전부 해소되기 전까진 공식 `10` 파일을 완주한 것으로 치지 말 것.**
|
||||
**상태**: 부분 확인(2026-08-13). `10-roblox-studio-checks.server.luau`(현재
|
||||
`luau-test/rewrite-required/`)의 공식 스크립트가 아니라 사용자가 별도로
|
||||
작성한 저수준 검증 스크립트로 확인됨 — `bindLifetime`/`unbindLifetime`
|
||||
자체의 이중 바인딩 로직과 Part B/C는 여전히 미검증. **아래 "아직 확인 안
|
||||
된 것"이 전부 해소되기 전까진 공식 `10` 파일을 완주한 것으로 치지 말 것.**
|
||||
|
||||
**[2026-08-14 다섯 번째 세션] 아래 실측 결과 자체는 전부 그대로 유효하고,
|
||||
오히려 더 중요해졌음** — `bindLifetime`/`canExecute`/`unbindLifetime`
|
||||
재정정으로 `canExecute(value)`가 **`value` 쪽 릴레이션에 복사된 gcconn의
|
||||
`.Connected`를 직접 읽는 것**이 leaf 경로 생존 판정의 전부가 됐기 때문
|
||||
(`base/lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime`
|
||||
— 확정" 절). 반면 이 문서가 인용하던 **`canBound`는 폐기**됐고(게이트는
|
||||
`canExecute` 하나로 통합), 공식 `10` 파일은 옛 모델을 검증 중이라
|
||||
`rewrite-required/`로 옮겨졌음 — 아래 시그니처 표기와 "아직 확인 안 된
|
||||
것"/"다음 확인 시 참고"를 그에 맞춰 갱신함. 역전 경위는
|
||||
`archive/canexecute-inst-arg-reversed.md`.
|
||||
|
||||
## 배경
|
||||
|
||||
|
|
@ -20,8 +31,8 @@ Studio 실측이 필요했음:
|
|||
하나로 생존을 판단하므로, 이게 틀리면 설계 전체가 무너짐.
|
||||
|
||||
`luau-test/README.md`는 이 스파이크(`10`의 A 섹션)가 실패하면(신호 발화
|
||||
또는 A-2 실패) "gcconn 트릭 전체를 재검토해야 하는 심각한 발견"이라고
|
||||
못 박아둔 상태였음.
|
||||
또는 재바인딩 게이트 실패) "gcconn 트릭 전체를 재검토해야 하는 심각한
|
||||
발견"이라고 못 박아둔 상태였음.
|
||||
|
||||
## 실측 방법
|
||||
|
||||
|
|
@ -32,7 +43,8 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아
|
|||
트릭과 동일한 신호를 구독, 콜백 클로저가 `target`을 업밸류로 캡처.
|
||||
- Connection을 변수로 안 잡는 경우(Test 1)와 잡는 경우(Test 2) 둘 다 확인.
|
||||
- `triggerGC()`로 GC 완료를 간접 관찰(기법 상세는
|
||||
`gc-trigger-helper.server.luau`, `luau-test/not-run/`).
|
||||
`gc-trigger-helper.server.luau`, `luau-test/not-run/` — 이 헬퍼는 그대로
|
||||
`not-run/`에 있음).
|
||||
|
||||
## 확인된 것
|
||||
|
||||
|
|
@ -41,10 +53,19 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아
|
|||
조건 1(신호 발화)은 회피 확인.
|
||||
2. **연결이 살아있는 동안 콜백 클로저가 캡처한 값이 GC 안 됨** — Test 1/2
|
||||
둘 다 `weak[1]`이 6 epoch 내내 살아있음. `lifecycle-pattern.md`의
|
||||
"클로저 생존이 곧 gchold 생존" 주장과 일치.
|
||||
"클로저 생존이 곧 gchold 생존" 주장과 일치. **[2026-08-14 세 번째
|
||||
세션]** 이 스크립트가 실제로 업밸류로 캡처한 값이 `target`(=Instance)
|
||||
자체였다는 점에서, 새 모델이 요구하는 **"클로저가 `gchold`뿐 아니라
|
||||
`inst`까지 캡처해 userdata 동일성을 고정한다"**는 조치의 전반부도 같이
|
||||
뒷받침됨 — 다만 "같은 엔진 객체를 다시 얻었을 때 userdata가 동일한가"
|
||||
자체는 이 스크립트가 확인한 바 없음(아래 미확인 목록).
|
||||
3. **`Connection.Connected`가 `Destroy()` 직후 동기적으로 `false`로 전환**
|
||||
— GC를 기다리지 않고 즉시 확인됨(Test 2). `canExecute(inst, value)`의
|
||||
유일한 하드 의존성이 실측으로 확인됨.
|
||||
— GC를 기다리지 않고 즉시 확인됨(Test 2). `canExecute(value)`의 유일한
|
||||
하드 의존성이 실측으로 확인됨. **[2026-08-14 다섯 번째 세션]** 재정정
|
||||
이후 이 항목의 무게가 더 커짐 — `canExecute`는 이제 `value` 쪽
|
||||
릴레이션에 복사된 gcconn의 `.Connected` **하나만** 보고 leaf 경로
|
||||
생존을 판정하므로(`inst`를 조회하는 경로가 아예 없음), 이 전환이
|
||||
즉발이 아니면 죽은 `inst`에 처리를 시도하는 것을 막을 방법이 없음.
|
||||
4. **Destroy 이후 GC를 한 번 더 돌리면 클로저가 캡처했던 값이 실제로
|
||||
수거됨** — `conn` 변수 자체는 스크립트 스코프에 여전히 남아있어도
|
||||
(Connection 객체 자체는 안 죽음), disconnect되면 콜백의 upvalue 참조는
|
||||
|
|
@ -52,12 +73,27 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아
|
|||
|
||||
## 아직 확인 안 된 것
|
||||
|
||||
- **A-1/A-2 (`canBound` 이중 바인딩 게이트, unbind 후 재바인딩 허용)** —
|
||||
`bindLifetime`/`canBound`/`unbindLifetime`/`Subscribed` 로직 자체는
|
||||
이 스크립트에 없음(순수 GC/Connection 메커니즘만 테스트함). `luau-test/README.md`가
|
||||
명시한 두 번째 "심각한 발견" 트리거 조건(A-2 실패 시
|
||||
`canBound`/`unbindLifetime` 설계 재검토)은 미해소 — 공식 `10` 파일을
|
||||
그대로 실행해서 확인해야 함.
|
||||
- **이중 바인딩 게이트 + unbind/Destroy 후 재바인딩 허용** — `bindLifetime`/
|
||||
`unbindLifetime` 로직 자체는 이 스크립트에 없음(순수 GC/Connection
|
||||
메커니즘만 테스트함). **[2026-08-14 다섯 번째 세션 갱신]** 게이트는 이제
|
||||
`canBound`가 아니라 `canExecute(value)` 하나이고(`if canExecute(v) then
|
||||
error(...) end`), 검증해야 할 명제도 바뀜: (a) 살아있는 바인딩을 가진
|
||||
값을 다시 `bindLifetime`하면 error, (b) `unbindLifetime(value)` 후에는
|
||||
통과, (c) **`inst`가 Destroy된 뒤에도 통과**(새 모델이 명시적으로
|
||||
허용 — `lifecycle-pattern.md` "`canBound` 폐기" 절). 전부 미해소이고,
|
||||
공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수 있음(현재
|
||||
`luau-test/rewrite-required/`).
|
||||
- **`bindLifetime`이 복사해둔 gcconn만으로 판정이 성립하는가** —
|
||||
**[2026-08-14 다섯 번째 세션 신규]** `canExecute`가 `inst`를 안 받고
|
||||
`BindData:GetWeak(value, "gcconn")` 하나로 생존을 판정하는 경로 자체는
|
||||
아직 실측된 적 없음(위 3번은 gcconn을 `inst` 쪽에서 직접 들고 있는
|
||||
형태로 확인한 것). weak 릴레이션에 복사해둔 참조가 gchold 사망 후
|
||||
기대대로 비워지는지도 같은 항목.
|
||||
- **Instance userdata 포인터 동일성** — **[2026-08-14 다섯 번째 세션 신규]**
|
||||
"Lua 쪽 강참조를 안 들고 있으면 나중에 같은 엔진 객체에서 다른
|
||||
userdata가 나올 수 있다"는 전제(gcconn/gchold를 Instance 생성 시점에
|
||||
만들기로 한 이유, `inst`-키 `Relate` 전체가 여기 기대고 있음)는 아직
|
||||
미검증.
|
||||
- **Part B (Attribute의 Instance 참조 타입 지원)**, **Part C
|
||||
(CollectionService 태그/`GetTagged` 왕복)** — 미실행.
|
||||
- **`inst` 자체를 `__mode="k"` weak key로 쓰는 경로** — 실제 `Relate`
|
||||
|
|
@ -86,7 +122,14 @@ GC가 실제로 완료되는 시점을 간접 관찰 가능함(사용자가 이
|
|||
|
||||
## 다음 확인 시 참고
|
||||
|
||||
공식 `10-roblox-studio-checks.server.luau`를 그대로 돌려서 A-1/A-2/B/C를
|
||||
마저 확인할 것 — 이 문서의 "확인된 것"과 겹치는 A 섹션 앞부분(신호 미발화,
|
||||
Destroy 시 Connected 전환)은 다시 안 봐도 되지만, `canBound`/`unbindLifetime`
|
||||
로직은 반드시 실제로 돌려봐야 함.
|
||||
**[2026-08-14 다섯 번째 세션 갱신] 공식 `10`은 그대로 돌리면 안 됨** — A
|
||||
섹션이 폐기된 모델(`canBound`, `bindLifetime`의 `.Subscribed` 세팅, 2-인자
|
||||
`canExecute`)을 검증 중이라 `luau-test/rewrite-required/`에 있음. 순서는
|
||||
**A 섹션 재작성 → Studio 실행**.
|
||||
|
||||
- 이 문서의 "확인된 것"과 겹치는 A 섹션 앞부분(ClassName 신호 미발화,
|
||||
Destroy 시 `Connected` 즉시 전환)은 다시 안 봐도 됨 — 단 **재작성 시
|
||||
이 두 검증은 반드시 남길 것**(새 모델에서 `canExecute`의 유일한 근거).
|
||||
- 반드시 실제로 돌려봐야 하는 것은 위 "아직 확인 안 된 것" 전부 —
|
||||
`canExecute` 게이트 3케이스(a/b/c), `value` 쪽 복사 gcconn만으로의 판정,
|
||||
Instance userdata 동일성, 그리고 손 안 댄 Part B/C.
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ quad/
|
|||
│ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절)
|
||||
│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준)
|
||||
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
|
||||
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
|
||||
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`)
|
||||
│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
|
||||
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
|
||||
│ └── init.luau
|
||||
|
|
|
|||
|
|
@ -385,6 +385,15 @@ retract/Destroy되면 자동으로 정리됨.
|
|||
- **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과
|
||||
동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님)
|
||||
— 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op.
|
||||
**[명시화, 2026-08-14 다섯 번째 세션] 이 게이팅이 일어나는 자리는 State의
|
||||
전파 루프**다 — State는 구독자를 **weak로** 담고, 발화 시 각 구독자마다
|
||||
`canExecute(observer)`를 확인해 거짓이면 그 구독자만 건너뜀. 여기에
|
||||
`inst`가 없다는 사실이 `canExecute`가 `value` 하나만 받아야 하는
|
||||
이유(`base/lifecycle-pattern.md`의 "실제 호출부" 절, 옛 2-인자
|
||||
시그니처의 역전 경위는 `archive/canexecute-inst-arg-reversed.md`).
|
||||
구독자를 weak로 담아도 되는 이유는 살려두는 책임이 State가 아니라
|
||||
`gchold`(leaf) 또는 전역 `Subscribed` 레지스트리에 있기 때문 — 어디에도
|
||||
안 묶인 Observer는 GC되어 구독 목록에서 자연히 빠짐.
|
||||
- **구현 노트(사용자 제안, 확정된 아키텍처는 아니고 구현 시 참고)**:
|
||||
살아있는 Observer 집합을 Observer 값 내부 필드로 안 두고, 외부에
|
||||
weak table(`{[observer] = true}`, `__mode = "k"`)로 인덱싱하는 방식을
|
||||
|
|
@ -483,15 +492,23 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
|
|||
로깅 껐다 켰다) 케이스에서, 참조를 끊어도 실제 GC는 결정론적으로 즉시
|
||||
일어나지 않음 — "껐다"고 생각한 뒤에도 한동안 계속 발화할 수 있음.
|
||||
`:Unsubscribe()`는 즉시/결정론적으로 끊는 경로라 이 문제가 없음.
|
||||
- **liveness 체크는 필드 우선, weak table은 폴백**(사용자 제안): 외부
|
||||
weak table 조회보다 리터럴 필드 접근이 더 쌈(Luau가 문자열 키 접근을
|
||||
미리 해시해둠) —
|
||||
- **liveness 체크는 두 경로를 하나의 predicate로 OR 묶음**(사용자 제안) —
|
||||
자동(리프 부착=`bindLifetime`)/수동(전역 `:Subscribe()`) 두 라이프사이클
|
||||
경로를 `canExecute(value)` 하나가 답함:
|
||||
```lua
|
||||
if self.Subscribed then return true end
|
||||
if self.Connection then return self.Connection.Connected end
|
||||
-- 개념 스케치. 확정 구현은 base/lifecycle-pattern.md가 소스
|
||||
local gcconn = BindData:GetWeak(self, "gcconn") -- leaf 경로(bindLifetime이 복사해둠)
|
||||
if gcconn ~= nil and gcconn.Connected then return true end
|
||||
return self.Subscribed == true -- 전역 경로(:Subscribe()만 세팅)
|
||||
```
|
||||
자동(리프 부착)/수동(구독) 두 라이프사이클 경로를 하나의 `canExecute`류
|
||||
predicate로 OR 묶는 자연스러운 형태. 실측은 구현 단계에서 확인.
|
||||
**[정정, 2026-08-14 다섯 번째 세션]** 이 절의 옛 스케치는 `self.Subscribed`를
|
||||
먼저 보고 `self.Connection`을 폴백으로 두는 모양이었는데, `.Subscribed`는
|
||||
**전역 경로 전용 필드라 리프 경로와 무관**하므로 우선순위 자체가 의미
|
||||
없음(두 경로는 상호 배타라 OR 순서는 성능 취향일 뿐). "필드 접근이 weak
|
||||
table 조회보다 싸다"는 관찰은 유효하지만, 그건 `.Subscribed`를 리프
|
||||
경로에도 겸용하라는 근거가 못 됨 — 실제로 2026-08-08 세션이 그렇게
|
||||
겸용했다가 `canExecute` 시그니처까지 오염됐음
|
||||
(`archive/canexecute-inst-arg-reversed.md`). 실측은 구현 단계에서 확인.
|
||||
- **내부 강참조 레지스트리**: `SubscribedObservers: {[observer]: true}`류를
|
||||
**weak 아닌 강참조**로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적
|
||||
자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림
|
||||
|
|
@ -504,11 +521,17 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
|
|||
no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌.
|
||||
- **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프)
|
||||
케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기
|
||||
해제는 `unbindLifetime(inst, value)`가 담당, `:Unsubscribe()`는
|
||||
전역 강참조 레지스트리 경로 전용으로 남음.** `inst`를 모르는
|
||||
`:Unsubscribe()`가 `bindLifetime`이 어느 `inst`에 등록했는지 찾아낼
|
||||
방법이 없어서(레지스트리가 `inst`별로 나뉘어 있음) 하나로 통합할 수
|
||||
없음 — 위 "이중 바인딩 금지" 절의 정정 참고.
|
||||
해제는 `unbindLifetime(value)`가 담당, `:Unsubscribe()`는
|
||||
전역 강참조 레지스트리 경로 전용으로 남음.** 둘이 지우는 대상이 서로
|
||||
다르기 때문 — `:Unsubscribe()`는 전역 레지스트리와 `.Subscribed` 필드를,
|
||||
`unbindLifetime`은 `inst`의 gchold 항목과 `value`가 들고 있던 gcconn
|
||||
참조를 지움. 위 "이중 바인딩 금지" 절의 정정 참고.
|
||||
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 들었던 이유(*"`inst`를
|
||||
모르는 `:Unsubscribe()`가 어느 `inst`에 등록했는지 찾아낼 방법이 없다"*)는
|
||||
이제 성립 안 함 — `unbindLifetime`도 `inst`를 안 받고 `value` 하나로
|
||||
해제함(`value`가 자기 홀더를 알고 있음). 결론(두 함수를 안 합침)은
|
||||
그대로지만 근거가 "찾을 수 없어서"가 아니라 "지우는 대상이 달라서"로
|
||||
바뀜.
|
||||
- **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상**
|
||||
— 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에
|
||||
담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심
|
||||
|
|
@ -534,7 +557,7 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양
|
|||
객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게
|
||||
체이닝 가능.
|
||||
|
||||
### 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정)
|
||||
### 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canExecute(value)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 `canBound`로 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정, **2026-08-14 다섯 번째 세션에 `canBound` 폐기·`canExecute`로 통합**)
|
||||
|
||||
**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를
|
||||
딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에
|
||||
|
|
@ -566,47 +589,50 @@ leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifeti
|
|||
0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다
|
||||
바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈.
|
||||
|
||||
**이름 확정 — `canBound(handle): boolean`, `canExecute`와 같은 결의
|
||||
탑레벨 함수(2026-08-09 세션, 가칭 `Bound` 필드를 직접 노출하는 대신).**
|
||||
`canExecute(inst, value)`가 "지금 살아있어서 실행돼도 되는가"를 묻는
|
||||
탑레벨 predicate인 것과 똑같이, "아직 어느 경로로도 안 묶였는가"도
|
||||
raw 필드(`self.Bound`)를 직접 보여주지 않고 같은 스타일의 탑레벨
|
||||
함수로 감싼다 — Observer/Effect 둘 다 쓰는 범용 predicate라 특정
|
||||
프리미티브 하나의 전용 소유물이 아니므로(`store-semantics.md`의
|
||||
네이밍 케이싱 기준: "이 이름이 특정 프리미티브 타입 하나의 전용
|
||||
소유물인가?"에 아니오라 소문자 탑레벨이 맞음, `architecture.md`
|
||||
"코드 스타일 — 네이밍 케이싱" 절과 같은 기준):
|
||||
**[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)`은
|
||||
폐기하고 `canExecute(value)` 하나로 통합.** 게이트는 이 모양:
|
||||
|
||||
```lua
|
||||
-- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침)
|
||||
-- — 둘 다 진입 전 동일하게 확인
|
||||
if not canBound(self) then
|
||||
error("Observer/Effect가 이미 다른 경로로 바인딩됨 — :Subscribe()와 bindLifetime(leaf 부착 포함)은 동시에 쓸 수 없음")
|
||||
if canExecute(self) then
|
||||
error(if self.Subscribed
|
||||
then "이미 :Subscribe()로 전역 바인딩된 값"
|
||||
else "이미 다른 Instance에 바인딩된 값")
|
||||
end
|
||||
-- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐)
|
||||
```
|
||||
|
||||
- `canBound(handle)`은 "이 핸들이 아직 어느 경로로도 안 묶였으면
|
||||
`true`, 이미 한 번 묶였으면 `false`"를 답하는 순수 predicate — 내부
|
||||
구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만,
|
||||
공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만
|
||||
바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는
|
||||
안 바뀜. **이 내부 플래그는 새 필드가 아니라 `canExecute`가 이미 보는
|
||||
`.Subscribed` 필드 그 자체(2026-08-09 여섯 번째 세션 명시)** —
|
||||
`:Subscribe()`뿐 아니라 `bindLifetime`도(Observer/Effect 값에 한해)
|
||||
이 필드를 `true`로 세팅, `:Unsubscribe()`/`unbindLifetime` 둘 다
|
||||
`false`로 되돌림 — 그래야 `bindLifetime`으로 등록된 Observer도
|
||||
`canExecute`가 정상적으로 "살아있음"으로 인식함(필드를 둘로 나누면
|
||||
`bindLifetime`으로만 등록된 Observer가 `canExecute`에서 항상
|
||||
`false`로 오판됨).
|
||||
- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만
|
||||
답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게
|
||||
대칭적으로 막힘.
|
||||
- **"이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은
|
||||
조건**이라 predicate를 둘로 나눌 이유가 없었음 — `canExecute`가
|
||||
참이면 그 값은 어딘가에 살아있는 바인딩을 갖고 있다는 뜻이고, 그게
|
||||
곧 "새로 묶으면 안 된다"임.
|
||||
- **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는
|
||||
**전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역,
|
||||
거짓인데 `canExecute`가 참이면 leaf 경로.
|
||||
- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한
|
||||
바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canExecute`를 확인하므로
|
||||
순서와 무관하게 대칭적으로 막힘.
|
||||
- **죽은 바인딩의 재사용은 허용** — `inst`가 Destroy됐거나
|
||||
`unbindLifetime`된 값은 `canExecute`가 거짓이라 게이트를 통과함(다른
|
||||
`inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐.
|
||||
|
||||
**[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는
|
||||
`canExecute`가 이미 보는 `.Subscribed` 필드 그 자체이고, `bindLifetime`도
|
||||
그 필드를 세팅한다"(2026-08-09 여섯 번째 세션)는 틀렸음.**
|
||||
`.Subscribed`는 **전역 `:Subscribe()`/`:Unsubscribe()` 전용 필드로,
|
||||
`bindLifetime`/`unbindLifetime`과는 일절 이해관계가 없다** — 이 둘은
|
||||
그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`이
|
||||
`value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`).
|
||||
옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된
|
||||
Observer가 `canExecute`에서 항상 `false`로 오판됨"은 실제로는 안 일어남
|
||||
— `canExecute`가 gcconn 경로를 **먼저** 보기 때문. 역전 원문·오염 경로·
|
||||
교훈은 `archive/canexecute-inst-arg-reversed.md`.
|
||||
- **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime`
|
||||
(leaf 부착 포함) 경로는 `unbindLifetime(inst, value)`로 해제** —
|
||||
(leaf 부착 포함) 경로는 `unbindLifetime(value)`로 해제** —
|
||||
둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이
|
||||
`unbindLifetime`도 대칭적으로 부르는 책임을 짐 — `inst`를 모르는
|
||||
`:Unsubscribe()`가 대신 처리할 수 없는 정보라서). leaf 부착으로
|
||||
`unbindLifetime`도 대칭적으로 부르는 책임을 짐). 지우는 대상이
|
||||
서로 다르므로 하나로 합칠 수 없음 — 위 `:Subscribe()` 절의 같은
|
||||
정정(2026-08-14 다섯 번째 세션) 참고. leaf 부착으로
|
||||
세워진 바인딩의 실제 해제도(예: Instance 파괴 전 조기 해제하고 싶을
|
||||
때) 결국 `unbindLifetime`이 담당 — 위 "`:Unsubscribe()`는 자동(리프)
|
||||
케이스에도 동일하게 씀" 절의 서술은 leaf 부착이 별도 메커니즘이라고
|
||||
|
|
@ -614,7 +640,7 @@ end
|
|||
`unbindLifetime`이 leaf 해제의 실제 통로).
|
||||
- **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로
|
||||
내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은
|
||||
`canBound` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect
|
||||
`canExecute` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect
|
||||
자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이
|
||||
이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf
|
||||
부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이
|
||||
|
|
@ -630,8 +656,8 @@ end
|
|||
|
||||
`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때
|
||||
쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은
|
||||
`canBound` 게이트를 확인** — Observer/Effect 값을 `bindLifetime`할 때도
|
||||
진입 전 `canBound(value)`를 확인하고, 통과하면 바인딩됨으로 표시.
|
||||
`canExecute` 게이트를 확인** — 진입 전 `canExecute(value)`를 확인하고,
|
||||
통과하면 gchold 등록 + gcconn 참조 복사를 수행.
|
||||
**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** —
|
||||
`Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면
|
||||
그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용"
|
||||
|
|
@ -643,28 +669,27 @@ leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만
|
|||
|
||||
```lua
|
||||
function bindLifetime(inst, value)
|
||||
local isOE = isObserver(value) or isEffect(value)
|
||||
if isOE and not canBound(value) then
|
||||
error("Observer/Effect가 이미 다른 경로로 바인딩됨")
|
||||
if canExecute(value) then
|
||||
error("이미 바인딩된 값") -- 메시지 분기는 위 게이트 스케치 참고
|
||||
end
|
||||
... -- gchold 등록(base/lifecycle-pattern.md)
|
||||
if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용
|
||||
... -- gchold 등록 + gcconn 참조 복사(base/lifecycle-pattern.md)
|
||||
end
|
||||
|
||||
function unbindLifetime(inst, value)
|
||||
... -- gchold 해제
|
||||
if isObserver(value) or isEffect(value) then value.Subscribed = false end
|
||||
function unbindLifetime(value)
|
||||
... -- gchold 항목 제거 + gcconn 참조 해제
|
||||
end
|
||||
```
|
||||
|
||||
- **비-Observer/Effect 값(예: Tween 내부에 쓰는 평범한 클로저)은 이 게이트
|
||||
자체가 안 적용됨** — `canBound`는 `.Subscribed`류 필드가 있는 Observer/
|
||||
Effect 전용 predicate라, 그 외 값은 `bindLifetime`이 그냥 통과시킴(leaf/
|
||||
`:Subscribe()` 경로 자체가 성립 안 하는 값들이라 충돌 대상이 없음).
|
||||
- Observer/Effect가 `bindLifetime`으로 바인딩된 뒤엔 `canBound`가
|
||||
`false`를 반환하므로, 그 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면
|
||||
기존 두 진입점의 기존 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가
|
||||
없이 이미 성립.
|
||||
- **[정정, 2026-08-14 다섯 번째 세션] 게이트는 값 타입을 안 가린다** —
|
||||
옛 서술은 "`canBound`는 `.Subscribed` 필드가 있는 Observer/Effect 전용
|
||||
predicate라 그 외 값(예: Tween 내부 클로저, Slot)은 그냥 통과"였는데,
|
||||
`canExecute`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 살아있는
|
||||
바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 마운트하는
|
||||
것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 실수를
|
||||
`bindLifetime` 층위에서도 공짜로 잡아줌.
|
||||
- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canExecute`가 참이 되므로, 그
|
||||
뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 기존 두 진입점의 기존
|
||||
체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 없이 이미 성립.
|
||||
|
||||
**quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는
|
||||
`archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만
|
||||
|
|
@ -936,8 +961,8 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사
|
|||
- `base/store-semantics.md`에 있던 "`isInit=false`면 허용, `isInit=true`+
|
||||
생존확인 거짓이면 불허" 분기 초안은 폐기. state-invalidate 리스너
|
||||
클로저도 `base/lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute
|
||||
predicate)로 등록하면, 발화 시 `canExecute(inst, value)`(2026-08-08 세션
|
||||
최종 시그니처) 하나만 확인하고 거짓이면
|
||||
predicate)로 등록하면, 발화 시 `canExecute(value)`(2026-08-14 세 번째
|
||||
세션 최종 시그니처, `inst`를 안 받음) 하나만 확인하고 거짓이면
|
||||
그냥 no-op — `isInit` 분기라는 별도 개념 자체가 불필요(사용자 확정:
|
||||
"canExecute 하나로 통일").
|
||||
|
||||
|
|
|
|||
|
|
@ -1069,7 +1069,7 @@ function Dispatch.setLength(inst, i, len)
|
|||
|
||||
local oldObserver = bk.observers[i]
|
||||
if oldObserver then
|
||||
unbindLifetime(inst, oldObserver) -- gchold 내부 구조 몰라도 됨
|
||||
unbindLifetime(oldObserver) -- gchold 내부 구조도, 어느 inst였는지도 몰라도 됨
|
||||
bk.observers[i] = nil
|
||||
end
|
||||
|
||||
|
|
@ -1191,7 +1191,7 @@ function StoreBind.process(inst, k, state, index)
|
|||
-- 자기 자신의 자원(Observer 구독)만 정리 — observer는 위 클로저가
|
||||
-- upvalue로 이미 캡처하고 있어 별도 Relate 저장/조회가 필요 없음
|
||||
-- (2026-08-13 다섯 번째 세션, 계약이 클로저 반환으로 바뀌며 단순화됨).
|
||||
unbindLifetime(inst, observer)
|
||||
unbindLifetime(observer) -- 1-인자(2026-08-14 다섯 번째 세션)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
|
@ -1208,7 +1208,7 @@ end
|
|||
바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라,
|
||||
`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임).
|
||||
|
||||
- **반환하는 클로저가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 —
|
||||
- **반환하는 클로저가 할 일은 `unbindLifetime(observer)` 호출뿐 —
|
||||
위임 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractFrom`이 자기
|
||||
밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이
|
||||
클로저는 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게
|
||||
|
|
@ -1219,11 +1219,16 @@ end
|
|||
캡처하므로, 예전처럼 `relate:SetStrong(inst,k,observer)`로 저장해뒀다가
|
||||
나중에 `relate:GetStrong(inst,k)`로 다시 찾아올 필요가 없어짐(위
|
||||
"핸들러 계약"/"핸들러 내부 상태 저장" 절 참고).
|
||||
- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가
|
||||
이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의
|
||||
`canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서
|
||||
특별 취급됨). `bindLifetime`도 이 `.Subscribed` 필드를 그대로
|
||||
세팅/해제하므로(위 "이중 바인딩 금지" 절 참고) 이 게이팅은 그대로 유효.
|
||||
- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — State의
|
||||
전파 루프가 발화 때마다 `canExecute(observer)`로 각 구독자를 게이팅하고,
|
||||
그 판정 근거(`inst` 생존)는 `bindLifetime`이 `observer` 쪽에 복사해둔
|
||||
gcconn 참조가 제공함(`base/lifecycle-pattern.md`의
|
||||
"`bindLifetime`/`canExecute`/`unbindLifetime`" 절).
|
||||
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목의 옛 근거(*"Observer가 이미
|
||||
자기 `Subscribed` 상태로 게이팅됨, `bindLifetime`도 그 필드를 세팅/해제"*)는
|
||||
틀렸음 — `.Subscribed`는 전역 `:Subscribe()` 전용 필드이고 `bindLifetime`은
|
||||
건드리지 않음. 결론(핸들러가 따로 안 짜도 됨)은 그대로, 근거만 바뀜.
|
||||
상세는 `archive/canexecute-inst-arg-reversed.md`.
|
||||
- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은
|
||||
코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회
|
||||
적용"을 별도로 안 짜도 되는 이유(`base/bind-system-plan.md`의 Observer 절의
|
||||
|
|
|
|||
|
|
@ -82,13 +82,17 @@ leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer
|
|||
함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해
|
||||
`bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`가
|
||||
`handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유:
|
||||
내부 Observer 자신의 재실행 게이팅(`canExecute`)이 "`Subscribed` 필드
|
||||
+ `inst`의 gcconn"을 함께 보는데, 후자는 그 Observer가 **직접**
|
||||
`bindLifetime(inst, observer)`된 적이 있어야만 올바른 `inst`를 참조함
|
||||
— `EffectHandle`만 바인드하고 내부 Observer는 안 하면, 그 Observer의
|
||||
`canExecute`가 `inst` 생존을 못 보고 엉뚱하게(또는 전혀) 게이팅됨.
|
||||
같은 이유로 `unbindLifetime(inst, handle)`도 내부 Observer까지 같이
|
||||
풀어야 대칭이 맞음.
|
||||
`canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이
|
||||
`bindLifetime(inst, observer)`될 때 그 Observer 쪽 릴레이션에
|
||||
복사되는 것**이라, `EffectHandle`만 바인드하고 내부 Observer는 안 하면
|
||||
그 Observer에겐 판정 근거가 아예 없어서 `canExecute`가 항상 거짓이 됨
|
||||
(=재실행이 통째로 죽음). 같은 이유로 `unbindLifetime(handle)`도 내부
|
||||
Observer까지 같이 풀어야 대칭이 맞음.
|
||||
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든
|
||||
"`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는
|
||||
틀렸음 — `.Subscribed`는 전역 `:Subscribe()` 전용이고 leaf 경로와
|
||||
무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는
|
||||
결론은 그대로이고 오히려 근거가 더 직접적이 됨.
|
||||
- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은
|
||||
전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observer`
|
||||
둘 다, 또는 `handle._observer`만으로 충분한지는 구현 세부 — 어느 쪽이든
|
||||
|
|
@ -145,8 +149,9 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것.
|
||||
3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복
|
||||
호출되면 안 됨** — 새 메커니즘 불필요, Observer가 이미 확정해둔
|
||||
"`Subscribed` 필드 우선 liveness 체크"가 자동(리프)/수동(Unsubscribe)
|
||||
두 경로를 하나의 게이트로 OR 묶어주므로 여기 그대로 얹힘.
|
||||
`canExecute(value)` liveness 체크가 자동(리프=gcconn 참조)/수동
|
||||
(전역=`Subscribed` 필드) 두 경로를 하나의 게이트로 OR 묶어주므로
|
||||
여기 그대로 얹힘.
|
||||
- **`state` 없는 mount-only Effect엔 특별한 분기 불필요** — install은 이미
|
||||
`Effect(fn)` 호출 시점에 끝나 있으므로, `:Unsubscribe()`는 그냥 "지금
|
||||
leaf-사망 cleanup을 수동으로 트리거"하는 것과 완전히 동치.
|
||||
|
|
@ -154,11 +159,12 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니
|
||||
동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩
|
||||
경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세
|
||||
규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound`
|
||||
플래그, 2026-08-09 세션에서 이름 확정)은
|
||||
규칙과 `canExecute(value)` 기반 즉시-에러 메커니즘(구 가칭 `Bound`
|
||||
플래그 → 2026-08-09 세션에 `canBound`로 명명 → **2026-08-14 세 번째
|
||||
세션에 `canBound` 폐기, `canExecute`로 통합**)은
|
||||
`base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정,
|
||||
2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`가
|
||||
아니라 `unbindLifetime(inst, value)`** — leaf 부착 자체가 내부적으로
|
||||
아니라 `unbindLifetime(value)`** — leaf 부착 자체가 내부적으로
|
||||
`bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime`
|
||||
전용(`:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함) — 금지되는 건
|
||||
여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함,
|
||||
|
|
|
|||
|
|
@ -131,7 +131,8 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
|
|||
실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결).
|
||||
|
||||
### `bindLifetime`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션,
|
||||
`unbindLifetime`은 2026-08-09 세션 추가)
|
||||
`unbindLifetime`은 2026-08-09 세션 추가, **시그니처는 2026-08-14 세 번째
|
||||
세션에 `value` 단독으로 최종 정정**)
|
||||
|
||||
**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/
|
||||
`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/
|
||||
|
|
@ -141,11 +142,27 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
|
|||
평평한 함수:
|
||||
|
||||
```lua
|
||||
bindLifetime(inst: any, value: any): ()
|
||||
unbindLifetime(inst: any, value: any): ()
|
||||
canExecute(inst: any, value: any): boolean
|
||||
bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐
|
||||
unbindLifetime(value: any): ()
|
||||
canExecute(value: any): boolean
|
||||
```
|
||||
|
||||
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는 `inst`를
|
||||
안 받는다 — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음.** 역전 원문과
|
||||
오염 경로 추적은 `archive/canexecute-inst-arg-reversed.md`. 요지: **"이 값이
|
||||
지금 실행돼도 되는가"는 `value` 자신에게 물어야 하는 질문**이고, 실제로 물을
|
||||
수 있다 — `bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value` 쪽
|
||||
릴레이션으로 복사해두기 때문(아래 구현). `inst`가 필요한 건 "어느 홀더에
|
||||
넣을 것인가"를 정해야 하는 `bindLifetime` 하나뿐.
|
||||
|
||||
이게 **구조적으로 중요한 이유**: `canExecute`의 실제 호출부는 State 전파
|
||||
루프(`emit`)다 — 그 자리엔 `inst`가 없고 있어서도 안 됨(State는 자기가 어느
|
||||
Instance에 걸렸는지 모르는 게 정상, 애초에 여러 곳에 걸릴 수 있음). 2-인자
|
||||
시그니처는 그 호출부에서 **호출 자체가 불가능**했고, 그래서 지금까지 어느
|
||||
문서에도 `canExecute`의 실제 호출부가 코드로 등장한 적이 없었음(서술만 있고
|
||||
코드가 없던 이유가 이것). 1-인자로 돌아오면서 호출부가 자연스럽게 성립함
|
||||
(아래 "실제 호출부" 절).
|
||||
|
||||
**`unbindLifetime` 추가 이유(2026-08-09 세션, `dispatch-core-plan.md`의
|
||||
"Length/Offset" 논의에서 파생)**: `Dispatch.setLength`(같은 위치에 새
|
||||
`State<number>`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함,
|
||||
|
|
@ -153,94 +170,220 @@ canExecute(inst: any, value: any): boolean
|
|||
특정 값 하나만 콜백/구독을 끊어야 하는 경우**가 실제로 생김 —
|
||||
`bindLifetime`만 있으면 그 호출부가 gchold의 내부 저장 구조(배열이든
|
||||
`value`를 키로 쓰는 테이블이든)를 직접 알아야만 특정 항목을 지울 수
|
||||
있어서 캡슐화가 깨짐. `unbindLifetime(inst, value)`을 짝으로 추가하면
|
||||
있어서 캡슐화가 깨짐. `unbindLifetime(value)`을 짝으로 추가하면
|
||||
호출부는 내부 구조를 몰라도 됨 — 구현이 쉬운 이유도 여기 있음(아래
|
||||
스케치처럼 gchold를 `value`를 키로 쓰는 테이블로 두면 `gchold[value] =
|
||||
nil` 한 줄). 안 걸려있던 값에 불러도 안전한 no-op(`:Unsubscribe()`류
|
||||
기존 관례와 동일).
|
||||
|
||||
**`unbindLifetime`이 `inst`를 안 받는 것의 실질 이득(2026-08-14 세 번째
|
||||
세션)**: 호출부가 "이 값을 *어느* inst에 걸었더라"를 기억할 필요가 없어짐 —
|
||||
`base/slot-plan.md`가 `unbindLifetime(slot._mountedInst, observer)`처럼
|
||||
`_mountedInst`를 되짚어 넘기던 자리가 전부 `unbindLifetime(observer)`로
|
||||
줄고, 그 과정에서 "`_mountedInst`가 이미 갈아치워졌거나 `nil`이면 해제가
|
||||
조용히 빗나간다"는 잠재 버그 클래스가 원천 소멸함(값 자신이 자기 홀더를
|
||||
알고 있으므로 빗나갈 대상이 없음).
|
||||
|
||||
base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가
|
||||
`BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute`
|
||||
관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md`의
|
||||
`Relate` 프리미티브 위에 얹힘(2026-08-08 세션, gchold를 `perInstanceState`
|
||||
직접 조작 대신 `Relate`로 구현):
|
||||
직접 조작 대신 `Relate`로 구현).
|
||||
|
||||
#### (0) gcconn/gchold는 **Instance 생성 시점**에 만든다 — `bindLifetime`이 아니라
|
||||
|
||||
**[2026-08-14 다섯 번째 세션 확정, 옛 lazy 생성에서 전환]** 예전 스케치는
|
||||
`bindLifetime` 첫 호출에서 gcconn을 lazy 생성했는데, 이건 **`inst`를 키로
|
||||
쓰는 모든 `Relate`의 전제를 깨는 구멍**이었음:
|
||||
|
||||
Roblox의 `Instance` 값은 엔진 객체 자체가 아니라 **엔진 객체를 가리키는
|
||||
userdata 포인터**다. Lua 쪽에서 아무도 참조를 안 들고 있으면 그 userdata는
|
||||
회수될 수 있고, 나중에 같은 엔진 객체를 `.Parent`/`:GetChildren()` 등으로
|
||||
다시 얻으면 **다른 userdata**가 나올 수 있음 — 그러면 이전 userdata를 키로
|
||||
저장해둔 `Relate` 항목 전체가 조용히 미아가 됨(`elementOwner`,
|
||||
`nameClaims`, Tag 참조카운트 등 `inst`-키 릴레이션 전부 해당). 따라서 quad는
|
||||
**자기가 만든 Instance마다 생성 즉시 Lua 쪽 강참조를 하나 심어** 바인딩이
|
||||
살아있는 동안 userdata 동일성을 고정한다:
|
||||
|
||||
```lua
|
||||
-- quad-roblox: Instance를 만든 직후 무조건 실행(핸들러/바인딩 유무와 무관)
|
||||
local nop = false or function(...) end -- local이라 상수 접힘/인라인 안 됨
|
||||
|
||||
local gchold = {} -- 이 inst에 매달린 값들의 강참조 홀더
|
||||
local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
|
||||
nop(gchold, inst) -- 절대 발화 안 함. 클로저가 gchold와 inst를 업밸류로 붙잡는 게 전부
|
||||
end)
|
||||
gchold[1] = gcconn -- 배열 자리 1번은 gcconn 전용(값들은 해시 자리에)
|
||||
|
||||
InstData:SetWeak(inst, "gchold", gchold)
|
||||
InstData:SetWeak(inst, "gcconn", gcconn)
|
||||
```
|
||||
|
||||
- **`ClassName`은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음**
|
||||
(rbvm 패턴 그대로) — 2026-08-13 부분 실측 확인(미발화 + Destroy 시
|
||||
`Connected` 즉시 전환), `audit/gcconn-trick-verification.md`.
|
||||
- **클로저가 `inst`까지 캡처하는 게 이번 변경의 핵심** — 예전 스케치는
|
||||
`gchold`만 캡처했음. `inst`를 캡처해야 위 userdata 동일성이 보장됨.
|
||||
- **`InstData`는 `SetWeak`** — gchold/gcconn은 이미 위 클로저↔`gchold[1]`
|
||||
상호 참조로 안전하게 살아있으므로, 릴레이션은 약하게만 잡으면 됨.
|
||||
**"다른 곳에서 안전하게 유지되는 것은 항상 weak로 잡는다"**가 일반
|
||||
규칙(강참조를 중복으로 걸면 실제 수명이 어디서 끝나는지가 흐려져 GC
|
||||
버그를 만들기 쉬움) — `base/relate-plan.md`의 상호 순환 경고와 같은 결.
|
||||
- **대가: quad가 만든 Instance는 참조를 놓는 것만으로는 회수되지 않고
|
||||
반드시 `Destroy`로 회수된다.** 클로저가 `inst`를 잡고, 그 클로저를
|
||||
`inst` 자신의 시그널이 잡는 순환이라 Destroy(=엔진이 커넥션을 끊음)가
|
||||
유일한 절단면. **실질적으로 새로 생긴 제약은 아님** — 실제 바인딩이
|
||||
하나라도 걸리면 그 Observer 클로저가 어차피 `inst`를 캡처해 같은 순환이
|
||||
생기므로(예: `dispatch-core-plan.md`의 `StoreBind.process`), 이번
|
||||
변경은 "아무것도 안 걸린 Instance"까지 같은 규칙으로 통일한 것뿐.
|
||||
|
||||
#### (1) `bindLifetime` / `unbindLifetime` / `canExecute`
|
||||
|
||||
```lua
|
||||
-- quad-roblox 실 구현 스케치
|
||||
local relate = Relate() -- 이 모듈 전용 인스턴스, 다른 핸들러와 key 충돌 없음
|
||||
local GCCONN = "__gcconn"
|
||||
local GCHOLD = "__gchold"
|
||||
local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐)
|
||||
local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움)
|
||||
|
||||
function bindLifetime(inst, value)
|
||||
local isOE = isObserver(value) or isEffect(value)
|
||||
-- leaf 부착도 내부적으로 이 함수를 호출하므로, :Subscribe()와 상호
|
||||
-- 배타적인 "이중 바인딩 금지"(base/bind-system-plan.md)를 여기서 확인
|
||||
if isOE and not canBound(value) then
|
||||
error("Observer/Effect가 이미 다른 경로로 바인딩됨")
|
||||
-- 이중 바인딩 금지(base/bind-system-plan.md) — 게이트가 곧 canExecute.
|
||||
-- "지금 실행 가능하다"는 곧 "이미 유효한 바인딩을 갖고 있다"는 뜻.
|
||||
if canExecute(value) then
|
||||
-- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건
|
||||
-- 인덱싱하면 안 됨 — 게이트는 값 타입을 안 가려서 value가 평범한
|
||||
-- 클로저일 수도 있음(그 경우 필드 접근 자체가 에러).
|
||||
local isGlobal = isObserver(value) or isEffect(value)
|
||||
if isGlobal then isGlobal = value.Subscribed == true end
|
||||
error(if isGlobal
|
||||
then "이미 :Subscribe()로 전역 바인딩된 값"
|
||||
else "이미 다른 Instance에 바인딩된 값")
|
||||
end
|
||||
|
||||
local gcconn = relate:GetStrong(inst, GCCONN)
|
||||
if not gcconn then
|
||||
-- ClassName은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음
|
||||
-- (rbvm 패턴 그대로) — 콜백 클로저가 gchold를 업밸류로 캡쳐해 살려둠
|
||||
local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로
|
||||
relate:SetStrong(inst, GCHOLD, gchold)
|
||||
gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
|
||||
local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존
|
||||
-- 2026-08-13 부분 실측 확인(미발화 + Destroy 시 Connected 즉시
|
||||
-- 전환) — audit/gcconn-trick-verification.md. canBound 이중
|
||||
-- 바인딩 게이트(A-1/A-2)는 아직 공식 10(`luau-test/not-run/`)으로 미확인.
|
||||
end)
|
||||
relate:SetStrong(inst, GCCONN, gcconn)
|
||||
end
|
||||
local gchold = relate:GetStrong(inst, GCHOLD)
|
||||
gchold[value] = true -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC
|
||||
if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용
|
||||
local gchold = InstData:GetWeak(inst, "gchold")
|
||||
gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1)
|
||||
-- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2).
|
||||
-- 둘 다 weak — gchold는 위 (0) 클로저가, gcconn은 gchold[1]이 이미 안전히 붙잡고 있음.
|
||||
BindData:SetWeak(value, "gchold", gchold)
|
||||
BindData:SetWeak(value, "gcconn", InstData:GetWeak(inst, "gcconn"))
|
||||
end
|
||||
|
||||
function unbindLifetime(inst, value)
|
||||
local gchold = relate:GetStrong(inst, GCHOLD)
|
||||
function unbindLifetime(value)
|
||||
local gchold = BindData:GetWeak(value, "gchold")
|
||||
if gchold then
|
||||
gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제
|
||||
end
|
||||
if isObserver(value) or isEffect(value) then value.Subscribed = false end
|
||||
BindData:SetWeak(value, "gchold", nil)
|
||||
BindData:SetWeak(value, "gcconn", nil)
|
||||
end
|
||||
|
||||
function canExecute(inst, value)
|
||||
-- Observer/Effect는 자기 바인딩 경로(bindLifetime=leaf 부착 포함,
|
||||
-- 또는 :Subscribe())의 생존 여부를 스스로 알고 있음 — inst가 살아있어도
|
||||
-- 이 값이 먼저 죽어 있을 수 있으므로(예: retract가 unbindLifetime만
|
||||
-- 하고 inst는 안 죽음) 반드시 먼저 확인.
|
||||
if (isObserver(value) or isEffect(value)) and not value.Subscribed then
|
||||
return false
|
||||
function canExecute(value)
|
||||
-- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음.
|
||||
-- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움
|
||||
-- (gchold가 죽으면 gcconn을 강참조하는 게 없어지므로 weak 항목이 스스로 비워짐).
|
||||
local gcconn = BindData:GetWeak(value, "gcconn")
|
||||
if gcconn ~= nil and gcconn.Connected then
|
||||
return true
|
||||
end
|
||||
local gcconn = relate:GetStrong(inst, GCCONN)
|
||||
return gcconn ~= nil and gcconn.Connected
|
||||
-- (b) 전역 경로: :Subscribe()가 세운 것. Observer/Effect에만 있는 필드.
|
||||
if isObserver(value) or isEffect(value) then
|
||||
return value.Subscribed == true
|
||||
end
|
||||
return false
|
||||
end
|
||||
```
|
||||
|
||||
**`canExecute`의 시그니처는 `(inst, value) -> boolean`(2026-08-08 세션,
|
||||
재정정 — 원래 있던 "`(handle) -> boolean`, zero-arg 아님" 결정을 대체함).**
|
||||
이전 라운드(2026-08-07 여덟 번째 세션)는 "등록마다 클로저를 새로 만들지
|
||||
않기 위해 zero-arg 대신 `handle` 인자를 받는다"까지만 확정했는데, 실제로
|
||||
`handle`이 뭘 가리키는지(단일 Connection? Observer 자신?)가 미정으로
|
||||
남아있었음 — 이번에 `(inst, value)` 2-인자로 구체화됨. 이유: Observer 자신의
|
||||
바인딩 생존(`Subscribed`)과 `inst` 자체 생존(gcconn)은 **독립적인 두 조건**이라
|
||||
하나의 opaque `handle`로 뭉치면 "inst는 살아있지만 이 Observer는 이미
|
||||
`:Unsubscribe()`됨" 케이스를 못 구별함 — 위 구현처럼 `value`의 타입에 따라
|
||||
분기해서 먼저 확인하고, 그 다음 `inst` 공유 gcconn을 봄. "canExecute 하나로
|
||||
전역 통일" 원칙(Slot 생존/Observer 게이팅/store-bind retract 전부 재사용)은
|
||||
안 바뀜, 시그니처만 구체화된 것.
|
||||
**`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부):
|
||||
|
||||
**Instance당 gcconn/gchold는 하나로 공유**(꼭 그럴 필요는 없지만 보통 그게
|
||||
싸서) — `bindLifetime`을 여러 값에 대해 여러 번 불러도 같은 `inst`면 같은
|
||||
`gcconn`/`gchold`를 재사용(첫 호출에서만 생성, 이후는 `relate:GetStrong`으로
|
||||
바로 찾음). `Relate`의 lazy 생성 자체가 이 재사용 비용을 이미 다뤄줌 —
|
||||
자세한 내부 구조는 `base/relate-plan.md`.
|
||||
1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]`
|
||||
강참조가 그것.
|
||||
2. **`value`는 `inst`가 살아있는지 스스로 확인할 방법을 갖는다** — `BindData`에
|
||||
복사된 gcconn 참조가 그것. `canExecute`가 `inst` 없이 성립하는 이유.
|
||||
|
||||
**실측 필요(M0/M2)**: Observer→liveness 역참조를 `value.Subscribed` 필드
|
||||
직접 읽기로 확정했으나(위 구현), 실제 Luau 필드 접근 비용/weak table 조회
|
||||
비용 비교는 여전히 quad-roblox 구현 단계에서 실측 확인 대상.
|
||||
**`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로
|
||||
전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도
|
||||
않음**. 옛 스케치가 `bindLifetime` 안에서 `value.Subscribed = true`를
|
||||
세팅하던 것이 이 문서의 오염 지점이었고, 그게 "`canExecute`가 `inst`를
|
||||
받아야 한다"는 잘못된 귀결까지 끌고 왔음(상세는
|
||||
`archive/canexecute-inst-arg-reversed.md`).
|
||||
|
||||
#### (2) 전역 경로 — `:Subscribe()`/`:Unsubscribe()`
|
||||
|
||||
`inst`에 안 묶이는(모듈 최상위 디버그 print류) Observer/Effect 전용. 상세
|
||||
규칙과 경고는 `base/bind-system-plan.md`의 "`:Subscribe()`/`:Unsubscribe()`"
|
||||
절이 소스이고, 여기선 `canExecute`가 보는 상태만 못박음:
|
||||
|
||||
```lua
|
||||
local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적)
|
||||
|
||||
function Observer:Subscribe()
|
||||
if canExecute(self) then -- bindLifetime과 정확히 같은 게이트
|
||||
error(if self.Subscribed
|
||||
then "이미 :Subscribe()된 값"
|
||||
else "이미 Instance에 바인딩된 값")
|
||||
end
|
||||
self.Subscribed = true
|
||||
Subscribed[self] = true
|
||||
return self
|
||||
end
|
||||
|
||||
function Observer:Unsubscribe()
|
||||
Subscribed[self] = nil
|
||||
self.Subscribed = false
|
||||
return self
|
||||
end
|
||||
```
|
||||
|
||||
`.Subscribed` 필드와 `Subscribed` 테이블이 **둘 다** 있는 이유: 테이블은
|
||||
강참조 루트(생존 보장), 필드는 `canExecute`가 매 발화마다 읽는 O(1) 경로 +
|
||||
에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이
|
||||
쓰고 같이 지우는 한 세트(`:Unsubscribe()`가 필드만 내리고 테이블을 안
|
||||
비우면 반쪽짜리 해제가 됨 — `bind-system-plan.md`에 이미 확정된 규칙 그대로).
|
||||
|
||||
#### (3) `canBound` 폐기 — 게이트는 `canExecute` 하나
|
||||
|
||||
**[역전, 2026-08-14 다섯 번째 세션]** 2026-08-09 세션에 이름 확정됐던
|
||||
별도 predicate `canBound(handle)`("아직 어느 경로로도 안 묶였는가")은
|
||||
**폐기하고 `canExecute(value)`로 통합**. 두 질문이 사실 같은 질문이기
|
||||
때문 — "이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은
|
||||
조건(위 구현의 (a) OR (b))이고, `canBound`의 내부 근거로 지목돼 있던
|
||||
`.Subscribed` 필드는 애초에 leaf 경로와 무관했으므로 그 정의 자체가
|
||||
성립하지 않았음.
|
||||
|
||||
부수 효과로 **"바인딩이 죽은 뒤의 재사용은 허용"**이 명시적 의미를 얻음 —
|
||||
`inst`가 Destroy됐거나 `unbindLifetime`된 `value`는 `canExecute`가 거짓이라
|
||||
게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는 게
|
||||
이 게이트의 의도.
|
||||
|
||||
#### (4) 실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다
|
||||
|
||||
`canExecute`가 "어디서 불리는가"는 지금까지 어느 문서에도 코드로 없었음(위
|
||||
정정 배너 참고). 확정된 위치는 **State의 전파 루프**:
|
||||
|
||||
- State는 자기 구독자(Observer의 emit 클로저)를 **weak로** 담는다 — 살려두는
|
||||
책임은 State가 아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에
|
||||
있고, 어디에도 안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐.
|
||||
- 발화 시 각 구독자에 대해 `canExecute(observer)`를 확인하고, 거짓이면
|
||||
**그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를 건드리는 시도가
|
||||
일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 실제 구현 지점.
|
||||
|
||||
**`state:Observer(fn)`의 "등록 즉시 1회 실행"은 이 게이팅과 무관**하다 —
|
||||
그건 Observer 생성자 자체의 계약이라 `bindLifetime` 이전에 동기적으로
|
||||
일어나고(그 시점엔 `canExecute`가 당연히 거짓), 게이팅 대상은 **그 이후의
|
||||
재실행**뿐. `base/slot-plan.md`/`base/dispatch-core-plan.md`가 이미 같은
|
||||
내용을 주석으로 달아둔 것과 같음.
|
||||
|
||||
**Instance당 gcconn/gchold는 하나로 공유** — `bindLifetime`을 여러 값에
|
||||
대해 여러 번 불러도 같은 `inst`면 같은 `gcconn`/`gchold`를 재사용(위 (0)에서
|
||||
Instance 생성 시 한 번만 만들어지고, 이후는 `InstData:GetWeak`으로 바로
|
||||
찾음). 자세한 내부 구조는 `base/relate-plan.md`.
|
||||
|
||||
**실측 필요(M0/M2)**: `canExecute`가 매 발화마다 `BindData:GetWeak(value,
|
||||
"gcconn")`(weak table 2단 조회)를 하는 비용이 실사용에서 문제되는지는
|
||||
quad-roblox 구현 단계에서 실측 확인 대상 — 문제가 되면 gcconn을 `value`의
|
||||
직접 필드로 내리는 선택지가 있음(옛 초안이 `self.Connection`으로 스케치했던
|
||||
모양). 지금 `Relate` 쪽으로 둔 이유는 "Observer 값 자체에 부작용을 안
|
||||
남기고 외부 weak 인덱싱을 선호"라는 기존 사용자 방침(`base/bind-system-plan.md`의
|
||||
`state:Observer(fn)` 절 구현 노트)이고, 성능 근거가 나오면 뒤집어도 되는
|
||||
순수 구현 세부.
|
||||
|
||||
이건 `base/dispatch-core-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate`
|
||||
직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,53 @@ weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강
|
|||
그래서 `Relate`는 판단을 안 하고 **`SetWeak`/`SetStrong`으로 호출부가 매번
|
||||
명시**하게 만드는 얇은 표면만 제공.
|
||||
|
||||
## 전제 — `inst` 키의 동일성은 공짜가 아니다 (2026-08-14 다섯 번째 세션 명시화)
|
||||
|
||||
**`Relate`가 `inst`를 키로 쓴다는 설계 전체가 "같은 엔진 객체를 가리키는
|
||||
`Instance` 값은 항상 같은 키다"를 전제하는데, Roblox에선 이게 자동으로
|
||||
보장되지 않는다.** Roblox의 `Instance` 값은 엔진 객체 자체가 아니라 **엔진
|
||||
객체를 가리키는 userdata 포인터**라, Lua 쪽에서 아무도 참조를 안 들고 있으면
|
||||
그 userdata가 회수될 수 있음 — 이후 같은 엔진 객체를 `.Parent`/
|
||||
`:GetChildren()` 등으로 다시 얻으면 **다른 userdata**가 나올 수 있고, 그러면
|
||||
이전 userdata를 키로 저장해둔 항목 전체가 조용히 미아가 됨(`elementOwner`,
|
||||
`nameClaims`, Tag 참조 카운트 등 `inst`-키 릴레이션 전부 해당 — 크래시가
|
||||
아니라 "부기가 그냥 없던 일이 되는" 조용한 오작동이라 더 위험).
|
||||
|
||||
**해결은 이미 있는 것으로 됨 — quad는 자기가 만든 Instance마다 생성 즉시
|
||||
gcconn 트릭을 걸고, 그 클로저가 `inst`를 캡처한다**(`base/lifecycle-pattern.md`의
|
||||
"gcconn/gchold는 Instance 생성 시점에 만든다" 절). 이 강참조 하나가
|
||||
Destroy 전까지 userdata 동일성을 고정해주므로, 모든 `inst`-키 `Relate`가
|
||||
그 위에서 성립함. **즉 gcconn 셋업은 `bindLifetime` 전용 배관이 아니라
|
||||
`Relate` 전체의 전제 조건이고, 그래서 바인딩 유무와 무관하게 Instance 생성
|
||||
시점에 무조건 실행된다**(옛 lazy 생성에서 이번에 전환된 이유).
|
||||
|
||||
**따름 정리 — quad 바깥에서 온 Instance를 `Relate` 키로 쓰는 건 UB.** quad가
|
||||
만들지 않은 Instance(`research/existing-instance-bind-plan.md`가 다루는
|
||||
영역)는 이 셋업을 안 거쳤을 수 있으므로, 그 스코프를 열 때 "키로 쓰기 전에
|
||||
gcconn 셋업을 먼저 건다"를 같이 설계해야 함.
|
||||
|
||||
## 일반 규칙 — 다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak` (2026-08-14 다섯 번째 세션)
|
||||
|
||||
값의 생존이 **이미 다른 경로로 보장돼 있다면** 릴레이션은 약하게만 잡는다.
|
||||
예: `gchold`는 gcconn 클로저가 업밸류로, `gcconn`은 `gchold[1]`이 각각
|
||||
붙잡고 있으므로 `InstData`/`BindData` 쪽은 전부 `SetWeak`
|
||||
(`base/lifecycle-pattern.md` 구현 스케치).
|
||||
|
||||
이유는 성능이 아니라 **디버깅 가능성** — 같은 값을 강참조로 두 번 잡으면
|
||||
"이 값의 실제 수명이 어디서 끝나는가"의 답이 둘이 되어, 한쪽만 지웠을 때
|
||||
안 죽는 조용한 누수가 생김. 강참조는 "여기가 이 값의 유일한 생존 근거"인
|
||||
자리에만 두고, 나머지는 전부 weak로 두면 그 질문의 답이 항상 하나로 유지됨.
|
||||
`SetStrong`을 쓰기 전에 **"이 값을 붙잡는 다른 근거가 이미 있는가"를 먼저
|
||||
확인**할 것 — 있으면 `SetWeak`가 맞다.
|
||||
|
||||
**부수 효과 — 이 규칙을 지키면 아래 "상호 강참조 순환" 위험이 구조적으로
|
||||
안 생긴다.** 그 위험은 *값이 강하게 보관될 때만* 성립하는데(값이 자기 키를
|
||||
되참조하는 경로가 강해야 ephemeron 부재가 문제됨), `SetWeak`면 릴레이션
|
||||
쪽에서 시작하는 강한 경로 자체가 없음. `lifecycle-pattern.md`의
|
||||
`InstData`/`BindData`가 실제 사례 — `gchold`가 `value`를 키로 강하게 잡고
|
||||
`value`가 `BindData`의 키이기도 하지만, `BindData` 쪽 보관이 weak라
|
||||
순환이 성립하지 않음.
|
||||
|
||||
## 위험한 패턴 — 서로 다른 두 `Relate`의 상호 강참조 순환 (2026-08-12 열세/열네 번째 세션, `Slot` 설계 중 발견)
|
||||
|
||||
위 26-28행이 말하는 "값이 자기 키를 다시 참조하는" 자기참조(예:
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ function SlotHandler.process(inst, k, slotValue, index)
|
|||
-- 죽는 중인 Source에 헛된 :Set()이 안 감, 아래 ⚠️ 절 참고)
|
||||
Dispatch.setOffsetSource(inst, k, None)
|
||||
Dispatch.setLength(inst, k, 0)
|
||||
unbindLifetime(inst, slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝
|
||||
unbindLifetime(slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝
|
||||
releaseOwner(slotValue, inst) -- 이제 이 Slot은 아무에게도 안 묶임(다른 곳에 다시 넣을 수 있음)
|
||||
end
|
||||
end
|
||||
|
|
@ -1110,7 +1110,7 @@ function activateList(self, inst)
|
|||
local data = self._listData
|
||||
if isState(data) then
|
||||
local observer = data:Observer(function() reconcile(data:Get()) end)
|
||||
-- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute/Subscribed
|
||||
-- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute
|
||||
-- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) —
|
||||
-- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속
|
||||
bindLifetime(inst, observer)
|
||||
|
|
@ -1224,9 +1224,13 @@ self.Length)`를 부르는 것과 같은 자리에서 같이 트리거되면 됨
|
|||
**canExecute와 "등록 즉시 1회 실행"의 관계 — 초기 실행은 게이팅과 무관하게
|
||||
무조건 일어남(사용자 확인)**: `data:Observer(fn)`가 등록되는 순간
|
||||
(`bindLifetime` 호출 *이전*) `fn`이 이미 한 번 동기 실행됨(Observer 자체의
|
||||
"등록 즉시 1회 실행" 계약) — 이 시점엔 아직 `bindLifetime`이 `Subscribed`를
|
||||
세팅 전이라 `canExecute`를 물으면 거짓이겠지만, 애초에 최초 실행은
|
||||
`canExecute`로 게이팅되는 대상이 아니라서 상관없음. `bindLifetime`은 그
|
||||
"등록 즉시 1회 실행" 계약) — 이 시점엔 아직 `bindLifetime`을 안 걸어
|
||||
`observer`에게 gcconn 참조가 없으므로 `canExecute`를 물으면 거짓이겠지만,
|
||||
애초에 최초 실행은 `canExecute`로 게이팅되는 대상이 아니라서 상관없음
|
||||
(**[정정, 2026-08-14 다섯 번째 세션]** 원래 "`bindLifetime`이 `Subscribed`를
|
||||
세팅 전이라"고 적혀 있었으나 `bindLifetime`은 그 필드를 안 건드림 —
|
||||
`.Subscribed`는 전역 `:Subscribe()` 전용,
|
||||
`archive/canexecute-inst-arg-reversed.md`). `bindLifetime`은 그
|
||||
직후에 걸려서 **이후의** 재실행(`data`가 다시 바뀔 때)만 게이팅 —
|
||||
`Dispatch.setLength`의 `bindLifetime(inst,observer)` 다음 줄에 있는
|
||||
"등록 즉시 1회와 겹쳐도 무해"라는 주석과 정확히 같은 구조.
|
||||
|
|
@ -1470,7 +1474,7 @@ local function unmountSlotTree(slot)
|
|||
local bk = getBookkeeping(slot)
|
||||
if bk then
|
||||
for i, observer in pairs(bk.observers) do
|
||||
unbindLifetime(slot._mountedInst, observer) -- 물리 target에 걸린 배관만 해제
|
||||
unbindLifetime(observer) -- 물리 target에 걸린 배관만 해제
|
||||
end
|
||||
end
|
||||
slot._mounted, slot._mountedInst = false, nil
|
||||
|
|
@ -1493,7 +1497,7 @@ local function destroySlotTree(slot)
|
|||
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
|
||||
if bk then
|
||||
for i, observer in pairs(bk.observers) do
|
||||
unbindLifetime(slot._mountedInst, observer)
|
||||
unbindLifetime(observer)
|
||||
end
|
||||
end
|
||||
-- [정정, 2026-08-13 감사] 마운트 상태도 되돌림 — 안 그러면 파괴된 Slot이
|
||||
|
|
@ -1520,7 +1524,7 @@ function rawUnmount(self, index)
|
|||
local element = self._elements[index]
|
||||
local bk = getBookkeeping(self)
|
||||
if bk.observers[index] then
|
||||
unbindLifetime(self._mountedInst, bk.observers[index])
|
||||
unbindLifetime(bk.observers[index])
|
||||
end
|
||||
releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
|
||||
if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end
|
||||
|
|
@ -1533,7 +1537,7 @@ function rawRemove(self, index)
|
|||
local element = self._elements[index]
|
||||
local bk = getBookkeeping(self)
|
||||
if bk.observers[index] then
|
||||
unbindLifetime(self._mountedInst, bk.observers[index]) -- outer가 이 위치 위해 등록해둔 observer
|
||||
unbindLifetime(bk.observers[index]) -- outer가 이 위치 위해 등록해둔 observer
|
||||
end
|
||||
releaseOwner(element, self) -- [정정, 2026-08-13 감사] 원래 이 줄이 의사코드에서
|
||||
-- 빠져 있었음(산문 쪽 "요소 소유권" 절은 rawRemove/
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ purity-and-effects-plan.md`와 연결됨).
|
|||
어떻게 되는가 — 별도 메커니즘을 새로 만들 필요 없이, `base/
|
||||
lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute predicate)을 state-
|
||||
invalidate 리스너 클로저 등록에도 그대로 재사용하면 됨: 발화 시
|
||||
`canExecute(inst, value)`(2026-08-08 세션 최종 시그니처 — `base/
|
||||
lifecycle-pattern.md` 참고) 하나만 확인, 거짓이면 no-op. 한때 검토했던 `isInit=false`면
|
||||
`canExecute(value)`(2026-08-14 다섯 번째 세션 최종 시그니처, `inst`를 안 받음
|
||||
— `base/lifecycle-pattern.md` 참고) 하나만 확인, 거짓이면 no-op. 한때 검토했던 `isInit=false`면
|
||||
허용/`isInit=true`+생존확인 거짓이면 불허 분기 초안은 폐기 — `canExecute`
|
||||
하나로 통일(사용자 확정). 상세는 `base/bind-system-plan.md`의
|
||||
"Store/State/Source 온톨로지" 절 참고.
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
| 폴더 | 뜻 | 누가 처리 |
|
||||
|---|---|---|
|
||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 |
|
||||
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff) | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용 `10` + GC 헬퍼) | 사용자 or MCP 연결 후 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음(14개) | — |
|
||||
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 3차 세션]** `canExecute` 1-인자 재정정) | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 3차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | — |
|
||||
|
||||
**스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 STATUS.md의
|
||||
줄도 같이 옮길 것** — 그게 곧 상태 갱신이다. 아래 파일 목록의 경로는
|
||||
|
|
@ -76,7 +76,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 |
|
||||
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>`가 `State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 |
|
||||
| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 |
|
||||
| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 — **[2026-08-13]** A 섹션 앞부분(신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됨, `audit/gcconn-trick-verification.md` 참고. A-1/A-2(`canBound` 게이트)/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
|
||||
| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`]** (A) `bindLifetime`/`unbindLifetime`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 `canBound`, `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 폐기됨(게이트는 `if canExecute(v) then error(...) end` 하나, `canExecute`는 `value` 단독 1-인자, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
|
||||
| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 |
|
||||
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
|
||||
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>`가 `Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) |
|
||||
|
|
@ -211,6 +211,17 @@ error)를 추가하고, `04`의 3~4단계를 "가드가 실제로 걸리는지 +
|
|||
대조해야 판정 가능 — 별도 진행. `15`는 `SyntaxError`로 파싱 자체가
|
||||
안 되는 상태(= 아무것도 검증 못 함)인 것만 먼저 확정.
|
||||
|
||||
**12차 (2026-08-14, 세 번째 세션, `10` → `rewrite-required/`)**:
|
||||
`bindLifetime`/`canExecute`/`unbindLifetime` 시그니처가 재정정되면서
|
||||
(`canExecute`/`unbindLifetime`이 `inst`를 안 받는 1-인자, `.Subscribed`는
|
||||
전역 `:Subscribe()` 전용이라 `bindLifetime`과 무관, 별도 predicate
|
||||
`canBound` 폐기, gcconn/gchold는 Instance 생성 시점 생성) `10`의 A 섹션이
|
||||
폐기된 모델을 검증하게 됨 — 코드 본문은 그대로 두고 헤더에 재작성 사유만
|
||||
달아 이동. **A가 검증하려던 것 중 "ClassName 신호 미발화 / Destroy 시
|
||||
Connected 즉시 전환"은 새 모델에서도 유효**(재작성 시 살릴 것). 정본은
|
||||
`base/lifecycle-pattern.md`, 역전 경위는
|
||||
`archive/canexecute-inst-arg-reversed.md`.
|
||||
|
||||
## 결과 확인 후 할 일
|
||||
|
||||
각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면
|
||||
|
|
@ -222,12 +233,24 @@ error)를 추가하고, `04`의 3~4단계를 "가드가 실제로 걸리는지 +
|
|||
- `07`이 예상대로 GC가 안 되는 것처럼 보이면(90개 안 죽는 것 같으면),
|
||||
`collectgarbage("count")` 수치 변화를 같이 알려줄 것 — 정확한 판정이
|
||||
어려운 항목이라 참고 신호로만 쓸 것.
|
||||
- `10`의 A 섹션에서 만약 `warn`이 실제로 뜨면(ClassName Changed가
|
||||
발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것
|
||||
— **[2026-08-13] 이 조건은 이미 회피 확인됨**(`audit/
|
||||
gcconn-trick-verification.md`), 재확인 불필요. A-2(재-bindLifetime 허용
|
||||
여부)가 실패하면 `canBound`/`unbindLifetime` 설계 자체를 재검토해야
|
||||
함 — **이건 아직 미확인, 공식 `10` 파일로 꼭 돌려볼 것.**
|
||||
- `10`은 **[2026-08-14 다섯 번째 세션] A 섹션을 먼저 재작성해야 함**(현재
|
||||
`rewrite-required/`) — 아래 판정 기준은 재작성 후에 적용할 것.
|
||||
- `warn`이 실제로 뜨면(ClassName Changed가 발화함) gcconn 트릭 전체를
|
||||
재검토해야 하는 심각한 발견이니 바로 알려줄 것 — **[2026-08-13] 이
|
||||
조건은 이미 회피 확인됨**(`audit/gcconn-trick-verification.md`),
|
||||
재확인 불필요.
|
||||
- 이중 바인딩 게이트는 이제 `canBound`가 아니라 **`canExecute(value)`
|
||||
하나**다(`if canExecute(v) then error(...) end`). 판정 기준도 이에
|
||||
맞춰 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시
|
||||
`bindLifetime`할 수 있어야 하고(게이트가 `canExecute` 거짓이라
|
||||
통과), **`inst`가 Destroy된 뒤의 재바인딩도 이제 명시적으로
|
||||
허용**임(살아있는 바인딩만 막는 게 게이트의 의도 —
|
||||
`lifecycle-pattern.md` "`canBound` 폐기" 절). 이게 실패하면
|
||||
`canExecute` 통합 설계 자체를 재검토해야 함 — **아직 미확인.**
|
||||
- 새로 검증할 항목: `bindLifetime`이 `value` 쪽 릴레이션에 복사해둔
|
||||
gcconn만으로 `inst` 생존을 판정할 수 있는가(=`canExecute`가 `inst`
|
||||
없이 성립하는가), gcconn/gchold를 **Instance 생성 시점**에 만들 때
|
||||
클로저가 `inst`까지 캡처해 userdata 동일성이 유지되는가.
|
||||
- `04`는 **[2026-08-13 여섯 번째 세션에 전면 재작성 — 옛 판정 기준 폐기]**
|
||||
두 시나리오를 연달아 돌림. **정상 설계**는 [1] 체인 깊이 3, [3] 옛 inner
|
||||
구독 0, [4] `STALE` 미반영이 기대값. **음성 대조군**은 [1] 깊이가 **1**로
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
# 스파이크 상태판 — **폴더가 곧 상태**
|
||||
|
||||
> 마지막 갱신: 2026-08-13 **열네 번째 세션**(하강 diff 재디스패치 확정으로
|
||||
> `04`/`19`가 옛 모델을 검증하고 있어 `rewrite-required/`로 이동).
|
||||
> 마지막 갱신: 2026-08-14 **세 번째 세션**(`bindLifetime`/`canExecute`/
|
||||
> `unbindLifetime` 재정정으로 `10`이 옛 모델을 검증하고 있어
|
||||
> `rewrite-required/`로 이동 — 이제 `not-run/`에는 스파이크가 없고 헬퍼만
|
||||
> 남음). 직전 갱신은 2026-08-13 열네 번째 세션(하강 diff 재디스패치 확정으로
|
||||
> `04`/`19` 이동).
|
||||
> 첫 실측은 여섯 번째 세션 — 상세 결과는 `.claude/audit/luau-test-first-run-2026-08-13.md`.
|
||||
> 실행법: `luau <파일>` (런타임) / `luau-analyze <파일>` (타입 전용).
|
||||
|
||||
**[2026-08-13 열세 번째 세션] `review-required/`가 비었습니다** — 마지막
|
||||
한 건이던 `08`이 해소돼 `done/`으로 갔습니다. 지금 남은 건 에이전트가
|
||||
처리할 일(`rewrite-required/`)과 Studio가 필요한 일(`not-run/`)뿐입니다.
|
||||
한 건이던 `08`이 해소돼 `done/`으로 갔습니다. **[2026-08-14 다섯 번째 세션]
|
||||
`not-run/`의 유일한 스파이크였던 `10`도 `rewrite-required/`로 갔습니다** —
|
||||
지금 남은 건 전부 에이전트가 먼저 재작성해야 할 일(`rewrite-required/`)이고,
|
||||
`not-run/`엔 스파이크가 아닌 헬퍼 하나만 있습니다.
|
||||
|
||||
| 폴더 | 뜻 | 개수 | 누가 처리 |
|
||||
|---|---|---|---|
|
||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 5 | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 1(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — |
|
||||
|
||||
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
|
||||
|
|
@ -38,7 +43,7 @@
|
|||
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
|
||||
아님(계약 자체는 위에서 이미 확정됨).
|
||||
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건)
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건)
|
||||
|
||||
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
|
||||
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
|
||||
|
|
@ -47,6 +52,12 @@
|
|||
"검증됨"으로 오독하게 되므로** 옮김. 새 정본은
|
||||
`base/dispatch-core-plan.md`/`base/attribute-plan.md`.
|
||||
|
||||
**[2026-08-14 다섯 번째 세션] `10`도 같은 이유로 합류** — `bindLifetime`/
|
||||
`canExecute`/`unbindLifetime` 재정정으로 A 섹션이 폐기된 모델(`canBound`,
|
||||
`bindLifetime`의 `.Subscribed` 세팅, 2-인자 `canExecute`)을 검증 중.
|
||||
`10`은 **Studio 전용이라 재작성해도 이 환경에서는 못 돌린다** — 재작성
|
||||
후 다시 `not-run/`으로 내려가 사용자/MCP를 기다리는 자리다.
|
||||
|
||||
| 파일 | 상태 | 무엇을 고쳐야 하나 |
|
||||
|---|---|---|
|
||||
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
|
||||
|
|
@ -54,12 +65,15 @@
|
|||
| `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 |
|
||||
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
|
||||
| `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 |
|
||||
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 폐기된 `canBound`/`bindLifetime`의 `.Subscribed` 세팅/2-인자 `canExecute`를 검증 중 — **`canExecute(value)` 1-인자 + `bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). 이중 바인딩 게이트도 `canBound`가 아니라 `if canExecute(v) then error(...) end`로. **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
|
||||
|
||||
## ⚪ `not-run/` — 이 환경에서 못 돌림
|
||||
|
||||
**[2026-08-14 다섯 번째 세션] 스파이크는 0건** — 유일했던 `10`이
|
||||
`rewrite-required/`로 갔음(위 표). 남은 건 헬퍼 하나뿐.
|
||||
|
||||
| 파일 | 이유 |
|
||||
|---|---|
|
||||
| `10-roblox-studio-checks.server.luau` | **Studio 전용**(`luau` CLI로 못 돌림). A 섹션 앞부분만 사용자 자작 스크립트로 실측 — `audit/gcconn-trick-verification.md`. **A-1/A-2(`canBound` 게이트)/B/C는 여전히 미확인** |
|
||||
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
|
||||
|
||||
## ✅ `done/` — 통과 or 판정 끝 (14건)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@
|
|||
참고(2026-08-09 세션 갱신 반영): 아래 makeStore의 `subscribe(fn)`은
|
||||
이 스파이크 전용으로 단순화한 것 — 실제 base 설계는 StoreBind가
|
||||
`state:Observer(fn)` + `bindLifetime(inst, observer)`/
|
||||
`unbindLifetime(inst, observer)`(`.claude/base/lifecycle-pattern.md`)
|
||||
`unbindLifetime(observer)`(2026-08-14 다섯 번째 세션에 unbind가
|
||||
1-인자로 정정됨, `.claude/base/lifecycle-pattern.md`)
|
||||
조합으로 구독/해제한다. 여기서 검증하려는 건 그 구독 배관이 아니라
|
||||
"우선순위 스캔+재귀 process/retract 자체가 Luau에서 잘 도는가"라서
|
||||
영향 없음 — 실제 Handler 구현 짤 때는 subscribe 대신 저 조합을 쓸 것.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
--[[
|
||||
⚠️ [2026-08-14 다섯 번째 세션] 재작성 필요 — 아래 A 섹션 코드는 **폐기된
|
||||
모델**을 검증한다: 별도 predicate `canBound`(→ `canExecute`로 통합 폐기),
|
||||
`bindLifetime`이 `value.Subscribed = true`를 세팅하는 것(→ `.Subscribed`는
|
||||
전역 `:Subscribe()` 전용이라 `bindLifetime`과 무관), 2-인자
|
||||
`canExecute(inst, value)`/`unbindLifetime(inst, value)`(→ 둘 다 `value`
|
||||
단독 1-인자). 새 모델은 `base/lifecycle-pattern.md`의
|
||||
"`bindLifetime`/`canExecute`/`unbindLifetime` — 확정" 절, 역전 경위는
|
||||
`archive/canexecute-inst-arg-reversed.md`.
|
||||
**단 A 섹션이 검증하려던 것 중 "ClassName 신호 미발화 / Destroy 시
|
||||
Connected 즉시 전환"은 새 모델에서도 그대로 유효하다** — 오히려 더
|
||||
중요해졌음(`canExecute`가 `value` 쪽에 복사된 gcconn의 `.Connected`를
|
||||
직접 읽는 게 leaf 경로 생존 판정의 전부). 그 두 가정은 이미 부분 실측됨:
|
||||
`audit/gcconn-trick-verification.md`. B/C 섹션은 이 변경과 무관하게 그대로
|
||||
유효하다. (재작성은 별도 작업 — Studio 전용이라 이 환경에서 검증도 못 함.)
|
||||
|
||||
검증 대상 (Roblox Studio 전용 — 순수 luau CLI로는 안 됨, 실제 Instance/
|
||||
Connection/CollectionService/Attribute가 필요함):
|
||||
|
||||
|
|
@ -100,9 +100,11 @@
|
|||
|
||||
사용자 원 메모: "quad는 register라던가 좀 부정확하거나 느낌이 바로 와닿지
|
||||
않던 용어들이 많음 — 전체적 용어를 보고 생각해볼래? 제안을 줘, 나도 같이
|
||||
볼게." **이미 확정된 이름**(`State`/`Relate`/`List`/`canBound`/`Ref`/
|
||||
볼게." **이미 확정된 이름**(`State`/`Relate`/`List`/`Ref`/
|
||||
`PreRef`/`Peek`/`isState`/`None`/`NoneHandler`/`Handler`)의 근거는
|
||||
`archive/question-resolved.md`.
|
||||
`archive/question-resolved.md`. (`canBound`는 2026-08-14 다섯 번째 세션에
|
||||
**폐기**되어 이 목록에서 빠짐 — `canExecute` 하나로 통합됐음,
|
||||
`archive/canexecute-inst-arg-reversed.md`.)
|
||||
|
||||
- **`DI`(Declarative Instance, 1순위)**: "Dependency Injection"의 업계
|
||||
표준 축약어와 완전히 겹침 — 4차 라운드에서 이미 한 번 실제로 오해가
|
||||
|
|
@ -119,7 +121,7 @@
|
|||
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
|
||||
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
|
||||
헷갈릴 수 있음.
|
||||
- **`canExecute`(3순위, 사소함)**: 실제로 "이 핸들이 아직 살아있나" 확인인데
|
||||
- **`canExecute`(3순위, 사소함)**: 실제로 "이 값이 아직 살아있나" 확인인데
|
||||
이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이
|
||||
있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열
|
||||
(`isState`/`isRef`/`isPreRef`/`isModifier`/`isObserver`류 — 전부 타입
|
||||
|
|
@ -127,7 +129,11 @@
|
|||
점이 지적됨. `canExecute`는 타입이 아니라 liveness(생존 여부)를 묻는
|
||||
질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가
|
||||
기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을
|
||||
같이 검토할 것.
|
||||
같이 검토할 것. **[2026-08-14 다섯 번째 세션] 열려 있는 건 이름뿐**
|
||||
— 시그니처는 `canExecute(value): boolean` 1-인자로 확정됐고(옛
|
||||
`(inst, value)` 2-인자는 폐기), 폐기된 `canBound`의 몫까지 이 하나가
|
||||
겸함(`base/lifecycle-pattern.md`, `archive/canexecute-inst-arg-reversed.md`)
|
||||
— 이름을 바꾸면 그 두 역할을 다 담아야 함에 유의.
|
||||
- **클로저 인자 이름 `hintValue`(3순위, 사소함, 2026-08-13 열네 번째
|
||||
세션 신설)**: 하강 diff 재디스패치에서 이 인자는 더 이상 "힌트"가
|
||||
아니라 **`nil`이거나 같은 핸들러가 곧 처리할 새 값**임이 계약으로
|
||||
|
|
|
|||
|
|
@ -187,10 +187,13 @@ Modifier/Ref를 아예 안 넘기는 케이스를 반드시 포함시킬 것.**
|
|||
|
||||
### 1-6. `canExecute`/`Connected`의 실제 구현 방식이 미확정인 채로 코어 전역에 이미 재사용 확정됨
|
||||
|
||||
**[해소됨, 2026-08-08 세션]** `bindLifetime(inst,value)`/`canExecute(inst,value)`
|
||||
**[해소됨, 2026-08-08 세션. 시그니처는 2026-08-14 다섯 번째 세션에 정정]**
|
||||
`bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)`
|
||||
탑레벨 함수로 확정, `Relate` 프리미티브(`base/relate-plan.md`) 위에 gcconn/
|
||||
gchold를 얹는 구체 구현까지 나옴 — `base/lifecycle-pattern.md`의
|
||||
"`bindLifetime`/`canExecute` — 확정" 절이 최신. 아래는 이 결정이 나오기
|
||||
"`bindLifetime`/`canExecute`/`unbindLifetime` — 확정" 절이 최신
|
||||
(뒤의 둘이 `inst`를 안 받게 된 경위는
|
||||
`archive/canexecute-inst-arg-reversed.md`). 아래는 이 결정이 나오기
|
||||
전까지의 문제 서술(정확했던 문제 인식이라 그대로 둠, 남은 실측 항목은
|
||||
`lifecycle-pattern.md` 쪽 "M0/M2 실측 필요" 캐비엇으로 이동).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
# 2026-08-14 네 번째 세션 — `ProcessedPreRef` 신설로 Length/Offset 등록 갭 해소, `PostRef` 완전 대칭화
|
||||
|
||||
## 배경 — 사용자 질문에서 시작된 읽기 전용 조사
|
||||
|
||||
사용자가 "PreRef 처리로 인해 공백이 생기면 Dispatch의 setLength/
|
||||
setOffsetSource가 안 터지는가"를 물으며 **읽기 전용**을 명시(다른
|
||||
에이전트가 같은 레포 파일을 편집 중이었음). Explore 에이전트로
|
||||
`base/ref-plan.md`(PreRef pre-pass)와 `base/dispatch-core-plan.md`
|
||||
(Length/Offset)를 조사한 결과:
|
||||
|
||||
- 설계상으로는 "충돌 없음"으로 다뤄져 있었음 — PreRef pre-pass가 소진시킨
|
||||
슬롯은 `Dispatch.setLength(inst,i,0)`/`setOffsetSource(inst,i,None)`으로
|
||||
등록돼야 한다고 `dispatch-core-plan.md`가 명시.
|
||||
- 그런데 **누가 그 등록을 실제로 호출하는지가 어느 문서에도 없는 진짜
|
||||
갭**이었음. 소진 값이 `None`이었기 때문에, 정상 두 패스 스캔이 그
|
||||
자리를 `Dispatch.process`/핸들러 매칭 자체를 안 거치고 드라이버 루프
|
||||
자신이 `if v == None then continue end`로 직접 건너뜀 — 그런데
|
||||
Length/Offset 등록 책임은 "이 위치를 처음 매치한 Handler"에게 있다고
|
||||
못박혀 있었으니(`dispatch-core-plan.md` "Length/Offset" 절), 애초에
|
||||
매치되는 Handler 자체가 없는 그 자리는 등록 주체가 없었음.
|
||||
|
||||
## 사용자 제안 1 — `ProcessedPreRef` 전용 센티널 + `ProcessedPreRefHandler`
|
||||
|
||||
사용자가 해법 제시: PreRef pre-pass 소진 값을 `None`이 아니라 전용
|
||||
센티널 `ProcessedPreRef`(단일 `{}`, `None`과 같은 급의 유니크 키)로 바꾸고,
|
||||
그 값을 매치하는 `ProcessedPreRefHandler`를 정상 우선순위 레지스트리에
|
||||
등록 — 이 Handler가 `setLength(0)`/`setOffsetSource(None)`을 등록하고
|
||||
no-op retract를 반환. 기존 "이미 있는 걸 재활용", "매치되는 Handler가
|
||||
곧 등록자"라는 원칙을 그대로 타서 특수 취급이 없어짐. `PreRefHandler`
|
||||
(동적 경로 가드, 정상 스캔에 원본 `isPreRef(v)`가 걸리면 error)는 그대로
|
||||
유지.
|
||||
|
||||
### 반영
|
||||
|
||||
- `base/ref-plan.md` "PreRef" 절 — `flattened[i] = None` → `= ProcessedPreRef`로
|
||||
교체, `ProcessedPreRefHandler` pseudocode 신설. 파생 서술 3곳도 같이
|
||||
정정:
|
||||
- "동적 경로 가드" Handler 설명의 "정상 두 패스 스캔에 다시 노출 안
|
||||
됨" → "이 가드 Handler에는 다시 노출 안 됨(스캔 자체엔 노출됨)"으로
|
||||
정확화.
|
||||
- "PreRef는 취소 개념이 없다"의 근거를 "체인에 안 올라감"에서
|
||||
"체인엔 올라가지만 retract가 하드코딩된 no-op(fire의 부작용을
|
||||
되돌릴 방법이 없어서)"로 정정 — `ProcessedPreRefHandler` 신설로
|
||||
"체인에 안 올라간다"는 옛 전제 자체가 깨졌기 때문.
|
||||
- sparse-table 회피 근거의 "None으로 소진" 표현을 "실재하는 센티널로
|
||||
소진"으로 일반화.
|
||||
- `base/dispatch-core-plan.md` — `None` 센티널 절과 Length/Offset 절
|
||||
두 곳에서 "PreRef pre-pass 소진 슬롯도 None 목록에 포함"이라던 서술을
|
||||
제거하고, `ProcessedPreRefHandler`가 그 등록을 전담한다고 명시. `
|
||||
sourceList`가 `None`을 쓰는 이유 문단의 PreRef 인용도 갱신.
|
||||
- `ROADMAP.md` M0/M8 체크리스트 — `None` 서술을 정정하고
|
||||
`ProcessedPreRefHandler` 구현 항목 신설.
|
||||
- `luau-test/done/02-none-sentinel-vs-nil-holes.luau` — 주석에 센티널
|
||||
개명 사실만 추가(스크립트가 검증하는 성질 자체는 "실재하는 non-nil
|
||||
값이면 순서/`#t`가 안 깨진다"는 일반 성질이라 어느 센티널을 쓰든
|
||||
결과는 유효, 재작성/재실행 불필요).
|
||||
|
||||
## 사용자 제안 2 — `PostRef`도 같이, 그런데 더 단순하게
|
||||
|
||||
사용자가 `research/lifecycle-hooks-plan.md`의 백로그 `PostRef` 스케치도
|
||||
같은 방식으로 갱신하자고 제안. 1차로 필자가 "PreRef는 소진 **후**
|
||||
값이 매치 대상, PostRef는 fire가 뒤로 미뤄지니 소진 **전** 원본 값이
|
||||
매치 대상이어야 한다"는 비대칭 설계를 초안으로 썼는데, 사용자가 더 나은
|
||||
배선을 제시: **PreRef pre-pass가 이미 배열 전체를 index 순서로 한 번
|
||||
훑고 있으니, 같은 스윕에서 `isPostRef(v)`도 같이 잡아 즉시
|
||||
`ProcessedPostRef`로 소진하고 그 인스턴스를 `postRefList`(그
|
||||
`Dispatch.drive` 호출 하나에만 로컬인 평범한 배열)에 순서대로 적재해두면
|
||||
된다.** 그러면:
|
||||
|
||||
- `PreRef`/`PostRef`가 **소진 메커니즘 완전 대칭**(둘 다 pre-pass에서
|
||||
즉시 `Processed*`로 소진, 둘 다 전담 `Processed*Handler`가
|
||||
Length/Offset 등록) — 유일한 차이는 "실제 콜백을 언제 부르는가"뿐.
|
||||
- 별도 후행 전체 재순회(두 번째 `for i=1,N`)가 필요 없어짐 — 두 패스가
|
||||
끝난 뒤 `postRefList`만 순서대로 소비하면 끝.
|
||||
- 1차 초안이 필요하다고 짚었던 "PostRef 전용 비대칭 규칙"(원본 값이
|
||||
매치 대상, `PostRefHandler`가 raw `PostRef`를 정상 스캔에서 잡아
|
||||
등록) 자체가 안 생김.
|
||||
|
||||
`research/lifecycle-hooks-plan.md`의 ② 절(`OnRendered`/`PostRef`, 여전히
|
||||
"의도적으로 지금 구현 안 함" 백로그 상태 그대로)을 이 설계로 갱신 — 스코프
|
||||
논의((a)/(b)/(c) 선택지)도 "후행 스캔" 표현을 "`postRefList` 소비"로
|
||||
정정.
|
||||
|
||||
## 검증
|
||||
|
||||
`python3 .claude/tools/doc-check.py` — 편집 전/후 모두 **ERROR 0건**,
|
||||
WARN 101건(개수 동일, 기존 false-positive 그대로 — `git stash`로 대조
|
||||
확인). 새로 도입한 문장이 만든 신규 WARN 없음.
|
||||
|
||||
## 커밋
|
||||
|
||||
사용자가 "지금 커밋해도 될듯"(워크트리 바깥 메인을 아무도 안 만짐)이라고
|
||||
해서 커밋 `e0ef7ce`(`docs(dispatch): PreRef 소진 센티널을
|
||||
ProcessedPreRef로 교체, Length/Offset 등록 갭 해소`)로 반영 — 로컬
|
||||
`main`에만, push 안 함(`SAFETY.md`).
|
||||
|
||||
## code-review 두 차례 시도, 결과 미도착
|
||||
|
||||
커밋 전에 사용자가 `/code-review high`를 두 번 돌림(첫 번째 실행 도중
|
||||
"커밋은 바로 하지 마, code-review 돌릴게"라고 지시). 두 번 다 8개(→5개)
|
||||
관점 파인더 중 마지막 하나가 안 끝난 상태로 세션 안에서 최종 결과가
|
||||
도착하지 않았음 — 사용자가 두 번째 실행도 "안 온다"며 포기하고 커밋을
|
||||
바로 지시. **code-review 결과 자체는 이 세션에 반영되지 않음** — 나중에
|
||||
결과가 도착하면 별도로 검토해 필요하면 후속 커밋으로 반영할 것.
|
||||
|
||||
## 반영 상태
|
||||
|
||||
새로 연 설계 질문 없음 — `ProcessedPreRef`/`ProcessedPreRefHandler`는
|
||||
이미 확정된 PreRef 메커니즘의 정제(같은 결론을 특수 취급 없이 만족시키는
|
||||
재배선)라 `question.md`에 올릴 항목 없음. `PostRef`/`OnRendered`는
|
||||
여전히 백로그 상태 그대로(우선순위·"착수 여부 미정" 변경 없음) — 착수
|
||||
시점에 이번에 정리된 대칭 설계를 그대로 가져다 쓰면 됨.
|
||||
187
.claude/session/2026-08-14-05-canexecute-value-scoped.md
Normal file
187
.claude/session/2026-08-14-05-canexecute-value-scoped.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# 2026-08-14 다섯 번째 세션 — `canExecute(value)` 1-인자로 정정, `Subscribed` 오염 제거, gcconn을 Instance 생성 시점으로
|
||||
|
||||
**발단**: 사용자가 `base/lifecycle-pattern.md`를 읽다가 한 줄에서 멈춤 —
|
||||
*"canExecute 가 왜 inst 를 받음? 이상하네. observer 등의 execute 가능 유무는
|
||||
이미 바운딩된 inst 가 존재해야하는거 아녔음? 에이전트가 그냥 잘못 말했나."*
|
||||
|
||||
## 1라운드 — 문서가 뭐라고 적혀 있었나
|
||||
|
||||
읽기만 요청받아 확인한 결과:
|
||||
|
||||
- `base/lifecycle-pattern.md`는 `canExecute(inst, value)` 2-인자를 확정으로
|
||||
적어두고 있었고, "2026-08-08 세션 재정정"이라는 배너까지 달려 있었음.
|
||||
근거는 *"Observer 자신의 바인딩 생존(`Subscribed`)과 `inst` 자체
|
||||
생존(gcconn)은 독립적인 두 조건이라 하나의 opaque handle로 뭉치면
|
||||
구별 못 함"*.
|
||||
- 그런데 **`canExecute`의 실제 호출부가 코퍼스 어디에도 코드로 없었음.**
|
||||
`dispatch-core-plan.md`의 `StoreBind.process` 의사코드에도 없고,
|
||||
`bind-system-plan.md`의 `state:Observer(fn)` 절은 "발화 시 canExecute로
|
||||
게이팅됨"이라고 **서술만** 함. `dispatch-core-plan.md`는 아예 "핸들러가
|
||||
직접 canExecute를 재구현할 필요 없음 — Observer가 이미 자기 `Subscribed`
|
||||
상태로 게이팅됨"이라고 적어 호출부를 없는 것처럼 만들어놨음.
|
||||
- 그리고 `bindLifetime`은 `value`에 `inst` 참조를 아무것도 안 남기고
|
||||
`value.Subscribed = true`만 세팅하고 있었음 → 문서가 주장하는
|
||||
"owning leaf가 죽으면 no-op"이 **`value`만 가지고는 성립 불가능**한 구조.
|
||||
|
||||
이 지점까지 정리해 보고하고, (a) 호출부 누락된 문서 갭이거나 (b) 원래
|
||||
1-인자였는데 어디선가 오염됐거나 둘 중 하나라고 추정.
|
||||
|
||||
## 2라운드 — 사용자가 정확한 모델을 직접 서술
|
||||
|
||||
*"또 에이전트가 실수한듯. Observer 의 바인딩은 Subscribed=true 안 함.
|
||||
정확히 Subscribed 는 글로벌 사이드에서 사용하기 위함이라고 **명시를 내가
|
||||
여러번 했음**. 다시한번 말하지만, Subscribed 는 bindLifetime 과 **일절
|
||||
이해관계가 엮이지가 않아.**"*
|
||||
|
||||
사용자가 못박은 `bindLifetime`의 계약 2줄:
|
||||
|
||||
1. Observer는 bind 계약이 유효한 중에는 `inst`만큼은 살 것이 보장된다.
|
||||
2. Observer는 `inst`가 살아있는지 **확인할 수 있는 방법이 제공된다**.
|
||||
|
||||
2번이 핵심 — "확인할 방법을 `value`가 제공받는다"는 게 `canExecute`가
|
||||
`inst` 없이 성립하는 이유이고, 옛 설계는 이 절반을 통째로 빠뜨린 채
|
||||
`inst`를 인자로 받아 때우고 있었던 것.
|
||||
|
||||
### 사용자가 제시한 Roblox 구현 (원문 요지)
|
||||
|
||||
`inst -> gchold`가 먼저 존재한다. **Instance는 엔진 객체가 아니라 엔진
|
||||
객체를 가리키는 포인터(userdata)라, Lua가 참조를 안 들면 지워지고 나중에
|
||||
`.Parent` 등으로 다시 얻으면 다른 포인터가 나올 수 있다.** 따라서:
|
||||
|
||||
```lua
|
||||
local nop = false or function(...) end -- local이라 인라인 안 됨
|
||||
local gchold = {nil}
|
||||
local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
|
||||
nop(gchold, inst)
|
||||
end)
|
||||
gchold[1] = gcconn
|
||||
```
|
||||
|
||||
*"이럼, inst 의 Destroy 이전 까지 inst 의 userdata 포인터는 유일하고,
|
||||
gchold 도 생명주기 상 유지됨. gcconn 도 유지돼. **이게 우리의 Instance
|
||||
생성 시 바로 할 일이 돼.**"*
|
||||
|
||||
그리고 `InstData:SetWeak(inst, "gchold"/"gcconn", ...)` — *"이미 gchold 는
|
||||
안전히 유지된다는 점. **안전히 유지된다면 항상 weak 를 써**, 안 그럼
|
||||
gc 에선 예상하기 힘들어지는 버그가 발생하기 쉬움."*
|
||||
|
||||
`bindLifetime(inst, v)`은 그 위에서:
|
||||
|
||||
```lua
|
||||
local gchold = InstData:GetWeak(inst, "gchold")
|
||||
gchold[v] = true -- 계약 1 (해시 멤버십 — 제거를 O(1)로)
|
||||
BindData:SetWeak(v, "gchold", gchold)
|
||||
BindData:SetWeak(v, "gcconn", InstData:GetWeak(inst, "gcconn")) -- 계약 2
|
||||
```
|
||||
|
||||
*"여기서 생각해볼 점은. gcconn 도 자동으로 제거된다는 점임. gcconn 의
|
||||
클로저가 죽으면 gchold 가 죽고 gcconn 을 강참조 하는건 없으니까"* — 즉
|
||||
`inst`가 Destroy되면 weak 항목이 스스로 비워져 `canExecute`가 자연히
|
||||
거짓이 됨(그 전 구간은 `.Connected == false`가 커버).
|
||||
|
||||
`unbindLifetime`은 그 역이고 **`inst`를 안 받음**. `canExecute`도 안 받음:
|
||||
|
||||
```lua
|
||||
local gcconn = BindData:GetWeak(v, "gcconn")
|
||||
if gcconn ~= nil and gcconn.Connected then return true end
|
||||
return v.Subscribed == true -- 글로벌 등록 경로
|
||||
```
|
||||
|
||||
마지막으로 호출부: *"이 이후, state 전파가 이걸 실행할지 말지를
|
||||
canExecute 로 담당하도록 emit 이 등록되는거임. **클로저로써, state 안에
|
||||
weak 로 들어가있지.**"* — 1라운드에서 "코드로 한 번도 안 나온다"고 지적한
|
||||
바로 그 자리가 여기서 확정됨.
|
||||
|
||||
또 *"Observer 에 콜백이 등록 되자 마자 바로 호출되는건 observer 자체 원래
|
||||
그런거고 이거랑 연관 없음"* — 최초 1회 실행은 게이팅 대상이 아님(기존
|
||||
`slot-plan.md` 주석과 일치).
|
||||
|
||||
## 3라운드 — 이중 바인딩 게이트, `canBound` 폐기
|
||||
|
||||
사용자가 이어서: ObserverHandler(leaf)와 `Subscribe` 둘 다 **먼저
|
||||
`canExecute`를 본다**. 참이면 "이미 사용중인 옵저버"라 에러 —
|
||||
*"좀더 명확하게, Subscribed 필드를 읽어서 글로벌인지, 리프인지 알려줘."*
|
||||
`Subscribe`는 `.Subscribed = true` + 전역 하드테이블 `[v]=true`,
|
||||
`Unsubscribe`는 그 역.
|
||||
|
||||
여기서 **`canBound(handle)` 폐기**가 따라나옴 — "아직 안 묶였는가"와
|
||||
"지금 실행 가능한가"가 같은 질문이고, `canBound`의 내부 근거로 지목돼
|
||||
있던 `.Subscribed` 겸용이 애초에 오염이었으므로 정의 자체가 성립 안 함.
|
||||
|
||||
부수 효과: **죽은 바인딩의 재사용은 허용**(Destroy됐거나 unbind된 값은
|
||||
게이트를 통과해 다른 `inst`에 다시 걸림). 막는 건 살아있는 이중 바인딩뿐.
|
||||
|
||||
### 확인 질문 2개 (사용자 스니펫의 오타)
|
||||
|
||||
1. `bindLifetime` 안의 `BindData:GetWeak(inst, "gchold")` — `InstData`에서
|
||||
읽어야 맞지 않나? → *"내 실수임. InstData 에서 가져오고 BindData에
|
||||
넣는거 맞음"*
|
||||
2. `unbindLifetime`의 `...[1] = nil` — 넣을 땐 해시(`gchold[v]=true`)였는데
|
||||
지울 때 배열 인덱스? → *"도 실수 맞음. 해시로 지우면 돼"*
|
||||
|
||||
## 오염 경로 추적 (archive에 보존)
|
||||
|
||||
- **2026-08-07 (더 오래된 초안)**: `bind-system-plan.md`의 `:Subscribe()`
|
||||
절에 이미 **올바른 1-인자 모양**이 있었음 —
|
||||
`if self.Subscribed then return true end` / `if self.Connection then
|
||||
return self.Connection.Connected end`. 즉 "값 자신이 자기 커넥션을
|
||||
들고 있다"가 원래 그림이었음.
|
||||
- **2026-08-08 (다섯 번째 세션)**: "재정정"이라는 이름으로 `(inst, value)`
|
||||
2-인자가 들어옴. `.Subscribed`에 "leaf 바인딩 생존"이라는 두 번째 의미를
|
||||
겹쳐 얹은 게 원인 — 그 순간 leaf 경로 생존을 `value`에게 물을 방법이
|
||||
사라져서 `inst` 조회가 유일한 경로가 됨. **2-인자는 증상이지 원인이
|
||||
아니었음.**
|
||||
- **2026-08-09 (여섯 번째 세션)**: 그 위에 `canBound`를 세우고, *"이 내부
|
||||
플래그는 `canExecute`가 이미 보는 `.Subscribed` 필드 그 자체"*라고
|
||||
명문화하며 오염을 고착.
|
||||
|
||||
**왜 여섯 세션을 살아남았나** — 호출부가 한 번도 의사코드로 안 적혔기
|
||||
때문. 진짜 호출부(State 전파 루프)엔 `inst`가 없어서 2-인자 시그니처는
|
||||
거기서 **호출 자체가 불가능**했는데, 아무도 그 코드를 써보지 않았음.
|
||||
`doc-check.py`가 잡을 수 있는 종류가 아님(문서 참조는 전부 정상이었음).
|
||||
|
||||
> **일반 교훈**: 계약(시그니처)을 정할 때 **호출부를 최소 하나는
|
||||
> 의사코드로 같이 적어둘 것.** "어디선가 게이팅됨"은 검증이 안 되는 문장.
|
||||
|
||||
## 부수 확정 — gcconn을 Instance 생성 시점으로 올린 것의 의미
|
||||
|
||||
사용자의 userdata 포인터 논거가 `bindLifetime`을 넘어 **`inst`를 키로 쓰는
|
||||
모든 `Relate`의 전제**임을 확인 — userdata가 회수되고 재생성되면
|
||||
`elementOwner`/`nameClaims`/Tag 참조카운트 항목이 전부 조용히 미아가 됨
|
||||
(크래시가 아니라 "부기가 없던 일이 되는" 오작동). 그래서 gcconn 셋업은
|
||||
바인딩 유무와 무관하게 Instance 생성 시 무조건 실행되어야 하고, 이걸
|
||||
`base/relate-plan.md`에 "전제" 절로 신설.
|
||||
|
||||
**대가**: 클로저가 `inst`를 캡처하므로 quad가 만든 Instance는 참조를 놓는
|
||||
것만으로는 회수 안 되고 `Destroy`가 유일한 절단면이 됨. 다만 **실질적으로
|
||||
새 제약은 아님** — 실제 바인딩이 하나라도 걸리면 그 Observer 클로저가
|
||||
어차피 `inst`를 캡처해 같은 순환이 생기므로(예: `StoreBind.process`),
|
||||
"아무것도 안 걸린 Instance"까지 같은 규칙으로 통일한 것뿐. 이 판단을
|
||||
`lifecycle-pattern.md`에 명시적으로 남김.
|
||||
|
||||
또 사용자의 *"안전히 유지된다면 항상 weak 를 써"*를 `relate-plan.md`에
|
||||
일반 규칙으로 승격 — 근거는 성능이 아니라 **디버깅 가능성**(강참조가 둘이면
|
||||
"이 값의 수명이 어디서 끝나는가"의 답이 둘이 되어 한쪽만 지웠을 때 조용한
|
||||
누수가 남음).
|
||||
|
||||
## 반영 결과
|
||||
|
||||
- `base/lifecycle-pattern.md` — 시그니처/구현 전면 재작성, (0) Instance 생성
|
||||
시점 셋업 / (1) bind·unbind·canExecute / (2) 전역 경로 / (3) `canBound`
|
||||
폐기 / (4) 실제 호출부 5개 절로 재구성.
|
||||
- `archive/canexecute-inst-arg-reversed.md` 신설 — 역전 원문 + 오염 경로 +
|
||||
"호출부를 안 적어서 못 잡았다"는 교훈.
|
||||
- `base/bind-system-plan.md` — `canBound` 절 역전, `:Subscribe()` 절
|
||||
스케치 정정, `state:Observer(fn)` 절에 실제 호출부 명시화.
|
||||
- `base/dispatch-core-plan.md` — `unbindLifetime` 1-인자 2곳, "Observer가
|
||||
자기 `Subscribed`로 게이팅" 근거 정정.
|
||||
- `base/relate-plan.md` — "전제 — `inst` 키의 동일성은 공짜가 아니다",
|
||||
"다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 두 절 신설.
|
||||
- `base/effect-plan.md` / `base/slot-plan.md`(호출부 5곳 + 주석 2곳) /
|
||||
`base/architecture.md` / `base/store-semantics.md` /
|
||||
`research/pre-implementation-audit.md` — 시그니처·근거 정정.
|
||||
- `luau-test`(`10`을 `rewrite-required/`로) / `audit/gcconn-trick-verification.md` /
|
||||
`ROADMAP.md` / `question.md` / `.claude/README.md` / `CLAUDE.md` — 동기화.
|
||||
|
||||
**새로 연 설계 질문 없음.** `question.md`의 `canExecute` 이름 3순위 항목은
|
||||
이름 논의라 그대로 열려 있음(시그니처는 이번에 확정).
|
||||
74
CLAUDE.md
74
CLAUDE.md
|
|
@ -242,11 +242,16 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
에러 격리 유틸 `Fallback`(**[2026-08-14 신설]** 컴포넌트 함수를 감싸 에러
|
||||
시 자동으로 플레이스홀더를 그려주는 `pcall`/`xpcall` 래퍼 — 기존
|
||||
"Error Boundary는 빈 자리 아님" 결론 위의 순수 슈가,
|
||||
`research/component-fallback-plan.md`) — 전부 "quad
|
||||
개발 상당 부분 끝난 뒤"로 사용자가 못박은 후순위. 상세는 `.claude/README.md`의
|
||||
`research/` 표(`debug-tooling-plan.md`/`documentation-plan.md`/
|
||||
`documentation-content-map.md`/`framework-comparison-findings.md`/
|
||||
`operator-sugar-plan.md`/`component-fallback-plan.md`).
|
||||
`research/component-fallback-plan.md`), 생명주기 훅
|
||||
`OnCreated`/`OnDestroyed`(**[2026-08-14 신설]** `PreRef`/`Effect`를
|
||||
반환하는 순수 팩토리 함수 슈가, `research/lifecycle-hooks-plan.md` —
|
||||
`OnRendered`는 base에 없는 post-pass가 필요해 공짜가 아니라 지금은
|
||||
의도적으로 구현 안 함, 거울상 `PostRef` 스케치만 백로그 후보) — 전부
|
||||
"quad 개발 상당 부분 끝난 뒤"로 사용자가 못박은 후순위. 상세는
|
||||
`.claude/README.md`의 `research/` 표(`debug-tooling-plan.md`/
|
||||
`documentation-plan.md`/`documentation-content-map.md`/
|
||||
`framework-comparison-findings.md`/`operator-sugar-plan.md`/
|
||||
`component-fallback-plan.md`/`lifecycle-hooks-plan.md`).
|
||||
5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중
|
||||
(`HUMAN_TODO.md` 2번 항목).
|
||||
|
||||
|
|
@ -1146,3 +1151,62 @@ vs xpcall, 패키지 배치, 이름, 프로덕션 동작) 정리, 설계 확정
|
|||
("파일:줄: ")를 자동으로 붙인다는 캐비엇을 새로 확인해 문서에 반영 —
|
||||
`research/component-fallback-plan.md`의 해당 열린 질문을 해소로 표시,
|
||||
백로그 우선순위 자체는 그대로.
|
||||
|
||||
**2026-08-14 세 번째 세션 — 생명주기 훅 `OnCreated`/`OnDestroyed` 백로그
|
||||
신설, `OnRendered`는 의도적 보류** (`session/2026-08-14-03-lifecycle-hooks-plan.md`)
|
||||
사용자가 React/Vue류 `OnCreated`/`OnRendered`/`OnDisposed`를 `PreRef`/
|
||||
`Effect` 위 슈가로 구현할 수 있을지 제안, 워크트리에서 조사 요청. 확인
|
||||
결과 `OnCreated(fn)`→`PreRef():Callback(fn)`, `OnDestroyed(fn)`→
|
||||
`Effect(function() return fn end)`는 호출 즉시 평가돼 기존 프리미티브
|
||||
인스턴스로 사라지는 순수 팩토리라 새 Dispatch/Brand 개념이 전혀 안
|
||||
생김(다중 등록도 자연 지원) — 이게 사용자가 처음 우려했던
|
||||
`:Compute`의 `State<function>` 문제가 애초에 안 생기는 이유와 같은
|
||||
뿌리임을 확인. `OnDisposed`(미래 `dispose()`와 이름 맞추기 제안)는
|
||||
검토 후 기각 — 훅의 실제 트리거는 `dispose()` 호출이 아니라 엔진
|
||||
`Destroying` 신호라 `OnDestroyed`가 더 정직함(`dispose()` 대상 범위가
|
||||
0-B로 아직 미확정이라 나중에 재검토 여지는 남겨둠). `OnRendered`는
|
||||
프로퍼티/이벤트 세팅 완료를 보장하는 훅이 base에 없어 `Dispatch.drive`에
|
||||
실제 post-pass가 필요하다는 게 드러나 공짜가 아님을 확인 — 사용자가
|
||||
**지금은 의도적으로 구현 안 하기로 확정**, 다만 `PreRef`의 거울상인
|
||||
`PostRef`(같은 메커니즘을 후행 스캔으로 뒤집기만 하면 됨)로 구현하면
|
||||
될 것 같다는 구체 스케치를 남겨 백로그 후보로 보존. `question.md`엔
|
||||
안 올림(이미 "지금 안 함"으로 답이 나온 질문이라). `research/lifecycle-hooks-plan.md`
|
||||
신설, README 인덱스 반영, 별도 워크트리에서 작업 후 메인에 수동
|
||||
반영(다른 세션이 동시에 메인에서 작업 중이라 병합 타이밍을 사용자가
|
||||
직접 조율) — 커밋 `9f9a68f`.
|
||||
|
||||
**2026-08-14 네 번째 세션 — `ProcessedPreRef` 신설로 Length/Offset 등록
|
||||
갭 해소, `PostRef` 완전 대칭화**
|
||||
(`session/2026-08-14-04-processedpreref-postref-symmetry.md`, 위 세 번째
|
||||
세션이 신설한 `research/lifecycle-hooks-plan.md`의 `PostRef` 스케치를
|
||||
이어받아 갱신)
|
||||
사용자의 "PreRef 소진으로 생기는 공백이 setLength/setOffsetSource를
|
||||
안 깨뜨리는가" 질문을 읽기 전용으로 조사한 결과, 소진 값이 `None`이라
|
||||
정상 두 패스가 그 자리를 아예 안 거쳐 "누가 그 등록을 호출하는가"가
|
||||
문서 어디에도 없는 진짜 갭임을 발견. 사용자가 전용 센티널
|
||||
`ProcessedPreRef`+`ProcessedPreRefHandler`(매치되는 Handler 자신이
|
||||
`setLength(0)`/`setOffsetSource(None)`을 등록)로 해소를 제안, `base/
|
||||
ref-plan.md`/`dispatch-core-plan.md`/`ROADMAP.md`에 반영(파생 서술 3곳
|
||||
정정 포함). 백로그 `PostRef`(`research/lifecycle-hooks-plan.md`)도 같은
|
||||
원리로 갱신하되, 사용자 제안으로 더 단순화 — 별도 후행 재순회 없이
|
||||
PreRef pre-pass 한 스윕에서 `isPostRef`도 같이 소진해 `postRefList`에
|
||||
적재해두는 안으로 Pre/Post 소진 메커니즘이 완전 대칭됨.
|
||||
`doc-check.py` ERROR 0 유지 확인 후 커밋(`e0ef7ce`). 같은 세션에
|
||||
`/code-review high`를 두 차례 시도했으나 둘 다 파인더 완료 전에 결과가
|
||||
도착하지 않아 리뷰 반영은 못 함 — 나중에 결과 도착 시 별도 검토 필요.
|
||||
|
||||
**2026-08-14 다섯 번째 세션 — `canExecute(inst,value)` 2-인자 역전,
|
||||
`.Subscribed` 오염 제거·`canBound` 폐기**
|
||||
(`session/2026-08-14-05-canexecute-value-scoped.md`)
|
||||
`canExecute`/`unbindLifetime`이 `inst`를 받던 2-인자 시그니처를 폐기하고
|
||||
`value` 단독으로 정정 — 뿌리는 2026-08-08에 들어온 "`bindLifetime`이
|
||||
`.Subscribed`를 세팅한다"는 오염이었고(그 필드는 전역 `:Subscribe()`
|
||||
전용), `bindLifetime`이 gcconn 참조를 `value` 쪽 `Relate`로 복사해두면
|
||||
생존을 `value` 하나로 물을 수 있음. 이 오염 위에 세워졌던 `canBound`는
|
||||
폐기되어 `canExecute` 하나로 통합, gcconn/gchold 생성도 lazy에서
|
||||
**Instance 생성 시점**으로 올라가며 클로저가 `inst`까지 캡처(userdata
|
||||
포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 여섯 세션을 살아남은
|
||||
이유가 "`canExecute`의 실제 호출부(State 전파 루프)가 어느 문서에도
|
||||
코드로 없었음"이라, 교훈으로 "계약을 정할 때 호출부를 최소 하나는
|
||||
의사코드로 같이 적을 것"을 남김. 정본은 `base/lifecycle-pattern.md`,
|
||||
역전 원문은 `archive/canexecute-inst-arg-reversed.md`.
|
||||
|
|
|
|||
|
|
@ -84,10 +84,20 @@ op만 주입(웹의 `className`/`data-*` 대응 때문). 상세는
|
|||
통과**했으나, `10-roblox-studio-checks.server.luau`만 **Studio 전용이라
|
||||
`luau` CLI로 못 돌림**. A 섹션 앞부분(ClassName 신호 미발화, Destroy 시
|
||||
`Connected` 즉시 전환)은 사용자가 자작 스크립트로 이미 확인
|
||||
(`.claude/audit/gcconn-trick-verification.md`) — **남은 건 A-1/A-2(`canBound`
|
||||
게이트)/B(Attribute의 Instance 참조 타입)/C(CollectionService 태그 왕복)**.
|
||||
GC 강제 트리거가 필요하면 같은 폴더의 `gc-trigger-helper.server.luau` 참고.
|
||||
위 1번(MCP 연결)이 되면 에이전트가 대신 돌릴 수도 있음.
|
||||
(`.claude/audit/gcconn-trick-verification.md`).
|
||||
|
||||
**[2026-08-14 다섯 번째 세션] 지금 바로 돌릴 수 있는 상태가 아님 — 먼저
|
||||
에이전트가 A 섹션을 재작성해야 함.** `bindLifetime`/`canExecute`/
|
||||
`unbindLifetime` 재정정으로 A가 폐기된 모델(`canBound`, `bindLifetime`의
|
||||
`.Subscribed` 세팅, 2-인자 `canExecute`)을 검증 중이라 파일이
|
||||
`.claude/luau-test/rewrite-required/`로 옮겨졌음. **남은 확인거리**는
|
||||
이중 바인딩 게이트(이제 `canExecute` 하나)와 unbind/Destroy 후 재바인딩
|
||||
허용, `value` 쪽에 복사된 gcconn만으로의 생존 판정, Instance userdata
|
||||
동일성, 그리고 B(Attribute의 Instance 참조 타입)/C(CollectionService
|
||||
태그 왕복) — 목록은 `.claude/audit/gcconn-trick-verification.md`의
|
||||
"아직 확인 안 된 것"이 소스. GC 강제 트리거가 필요하면
|
||||
`.claude/luau-test/not-run/gc-trigger-helper.server.luau` 참고. 위
|
||||
1번(MCP 연결)이 되면 에이전트가 대신 돌릴 수도 있음.
|
||||
|
||||
## 6. **[2026-08-13 신설, 안 막음]** 에디터의 Luau 솔버 설정 확인
|
||||
|
||||
|
|
|
|||
88
ROADMAP.md
88
ROADMAP.md
|
|
@ -138,27 +138,33 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를
|
||||
대체(2026-08-08 세션 신설).
|
||||
- [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/
|
||||
`unbindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수
|
||||
`unbindLifetime(value)`/`canExecute(value)` 탑레벨 함수
|
||||
타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만
|
||||
있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이
|
||||
이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼
|
||||
있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md`
|
||||
2번 — 2026-08-07 네 번째 세션에 반영).
|
||||
**`canExecute`는 `(inst, value) -> boolean`으로 재확정(2026-08-08
|
||||
세션, `(handle)` 단일 인자 서술을 대체)** — Observer/Effect는 자기
|
||||
`Subscribed` 상태를 먼저 확인, 그 다음 `inst`의 공유 gcconn(`Relate`로
|
||||
저장)의 `.Connected`를 봄. **`unbindLifetime(inst,value)` 추가
|
||||
(2026-08-09 여섯 번째 세션)** — `inst` 전체 죽기 전에 특정 값 하나만
|
||||
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는
|
||||
`inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음.
|
||||
`bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value` 쪽
|
||||
`Relate`로 복사해두므로 "지금 실행돼도 되는가"를 `value` 하나로 물을 수
|
||||
있고, `canExecute`의 실제 호출부(State 전파 루프)엔 `inst`가 없어서
|
||||
2-인자로는 호출 자체가 불가능했음. 판정은 (a) 복사된 gcconn의
|
||||
`.Connected` 또는 (b) Observer/Effect의 `.Subscribed` 둘 중 하나 —
|
||||
**`.Subscribed`는 전역 `:Subscribe()` 전용 필드라
|
||||
`bindLifetime`/`unbindLifetime`이 읽지도 쓰지도 않음**. 역전 원문은
|
||||
`archive/canexecute-inst-arg-reversed.md`.
|
||||
**`unbindLifetime(value)` 추가(2026-08-09 여섯 번째 세션)** —
|
||||
`inst` 전체 죽기 전에 특정 값 하나만
|
||||
조기 해제(`Dispatch.setLength`가 State 재등록 시 이전 Observer를
|
||||
정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화.
|
||||
`bindLifetime`/`unbindLifetime`/`canExecute` 셋 다 네임스페이스
|
||||
없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분,
|
||||
`isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/
|
||||
lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime`
|
||||
— 확정" 절 참고. **Observer/Effect 값에는 `bindLifetime`/
|
||||
`unbindLifetime`도 M3의 `canBound` 게이트를 확인/세팅** — children
|
||||
배열 leaf 부착이 실제로는 `bindLifetime` 호출이라서(M3 체크박스
|
||||
참고, 구현 순서상 M2가 M3의 `canBound`를 참조하게 됨에 유의)
|
||||
— 확정" 절 참고. **이중 바인딩 금지 게이트도 `canExecute` 하나로
|
||||
통합**(별도 `canBound`는 폐기 — M3 체크박스 참고), children 배열 leaf
|
||||
부착이 실제로는 `bindLifetime` 호출이라 이 게이트를 그대로 탐
|
||||
- [ ] `Dispatch.setLength(inst,i,len:number|State<number>)`/
|
||||
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|None)` —
|
||||
array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브
|
||||
|
|
@ -216,6 +222,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
## M3 — Store/State/Source
|
||||
|
||||
- [ ] `Source.luau`/`State.luau`/`Store.luau`
|
||||
- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅**
|
||||
(2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제
|
||||
호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) —
|
||||
State는 구독자(Observer의 emit 클로저)를 **weak로만** 담고, 살려두는
|
||||
책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음
|
||||
(어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). 발화 시
|
||||
각 구독자에 대해 `canExecute(observer)`가 거짓이면 **그 구독자만
|
||||
조용히 건너뜀**(no-op) — 이게 `canExecute`의 유일한 실제 호출부이고,
|
||||
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
|
||||
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
|
||||
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
|
||||
- [ ] `store.key` dot-access 타입 추론 확인 — Luau `type function`
|
||||
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>`가 `T`의 각 필드를
|
||||
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
|
||||
|
|
@ -254,15 +271,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
|
||||
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
|
||||
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션)
|
||||
- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로
|
||||
- [ ] Observer/Effect 이중 바인딩 금지 — `canExecute(value)` 게이트로
|
||||
`:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도
|
||||
내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/
|
||||
bind-system-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째
|
||||
세션 신설, 이름은 2026-08-09 세션에 `canBound`로 확정, 같은 날
|
||||
여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜
|
||||
독립 경로는 둘뿐). `canBound`의 내부 플래그는 `canExecute`가 보는
|
||||
`.Subscribed`와 같은 필드 — `bindLifetime`/`unbindLifetime`도
|
||||
(Observer/Effect 값에 한해) 이 필드를 세팅/해제
|
||||
세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime
|
||||
호출"로 정정 — 진짜 독립 경로는 둘뿐).
|
||||
**[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)`
|
||||
(2026-08-09 세션에 이름 확정됐던 것)은 폐기** — "이미 유효하게 묶여
|
||||
있다"와 "지금 실행 가능하다"가 정확히 같은 조건이라 `canExecute`
|
||||
하나로 통합됨. `canBound`의 내부 근거로 지목돼 있던 `.Subscribed`
|
||||
필드는 애초에 leaf 경로와 무관했고(전역 `:Subscribe()` 전용),
|
||||
leaf 생존 판정은 `bindLifetime`이 `value` 쪽 `Relate`에 복사해둔
|
||||
gcconn으로 함 — `base/lifecycle-pattern.md`의 "`canBound` 폐기" 절,
|
||||
역전 경위는 `archive/canexecute-inst-arg-reversed.md`. 부수 효과로
|
||||
**바인딩이 죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를
|
||||
통과**(살아있는 바인딩만 막는 게 의도)
|
||||
- [ ] mock 대상 테스트
|
||||
|
||||
## M4 — 첫 end-to-end 반응형 업데이트
|
||||
|
|
@ -288,6 +312,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드)
|
||||
- [ ] `DI/init.luau`(제네릭 생성자 + ~25개 정적 필드)
|
||||
- [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau`
|
||||
- [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션
|
||||
확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/
|
||||
lifecycle-pattern.md`의 "(0) gcconn/gchold는 Instance 생성 시점에
|
||||
만든다" 절) — quad가 만든 모든 Instance에 대해 **핸들러/바인딩 유무와
|
||||
무관하게 생성 직후 무조건** `GetPropertyChangedSignal("ClassName")`
|
||||
연결(절대 발화 안 함)로 gcconn을 만들고 `gchold[1]=gcconn`,
|
||||
`InstData:SetWeak(inst,"gchold"/"gcconn",...)`. **클로저가 `gchold`와
|
||||
`inst`를 둘 다 캡처해야 함** — Instance userdata 포인터 동일성을
|
||||
고정하는 게 목적이고, 그래야 `inst`를 키로 쓰는 모든 `Relate`
|
||||
(`elementOwner`/`nameClaims`/Tag 참조카운트 등)가 성립함. 대가는
|
||||
"quad가 만든 Instance는 참조를 놓는 것만으로 회수되지 않고 반드시
|
||||
`Destroy`가 필요" — 바인딩이 하나라도 걸리면 어차피 같은 순환이
|
||||
생기므로 실질적 신규 제약은 아님
|
||||
- [ ] 실제 Roblox에서 첫 `Frame{...}` 렌더 확인 — **Studio 작업이라
|
||||
`HUMAN_TODO.md` 1번(계정 분리) 먼저 되어야 진행 가능, `SAFETY.md` 준수**
|
||||
|
||||
|
|
@ -566,10 +603,21 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`thread`가 `nil`이면
|
||||
`coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self`
|
||||
반환(남의 thread를 여기서 대신 정지시킬 수 없어서)
|
||||
- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/`canExecute`
|
||||
본체(`GetPropertyChangedSignal("ClassName")` 연결 트릭으로 gcconn 확보,
|
||||
`Relate:SetStrong`으로 gcconn/gchold 저장 — 인터페이스 자체는 M2로
|
||||
이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 없음)
|
||||
- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/
|
||||
`unbindLifetime`/`canExecute` 본체(인터페이스 자체는 M2로 이동됨,
|
||||
`Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 없음).
|
||||
**[2026-08-14 다섯 번째 세션 정정] gcconn/gchold를 여기서 lazy 생성하지
|
||||
않는다** — 생성은 M5의 Instance 생성 경로가 이미 끝내둔 것이고, 이
|
||||
함수들은 `InstData`에서 찾아 쓰기만 함. `bindLifetime`은
|
||||
`gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value,
|
||||
"gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게)
|
||||
둘만 하고, `unbindLifetime(value)`은 그 셋을 되돌림, `canExecute(value)`은
|
||||
복사된 gcconn의 `.Connected` 또는 `.Subscribed`를 봄.
|
||||
**저장은 전부 `SetWeak`**(`SetStrong` 아님 — gchold/gcconn은 아래 M5
|
||||
클로저↔`gchold[1]` 상호 참조로 이미 안전하게 살아있고, "다른 곳에서
|
||||
안전하게 유지되는 것은 항상 weak로 잡는다"가 일반 규칙).
|
||||
`base/lifecycle-pattern.md`의 "`bindLifetime` / `unbindLifetime` /
|
||||
`canExecute`" 절
|
||||
|
||||
## M9 — 컴포넌트 합성 레이어
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue