From af513aeb843bda94db338e0508cf3c4b9fb90902 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 14 Aug 2026 04:03:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(lifecycle):=20canExecute/unbindLifetime?= =?UTF-8?q?=EC=9D=84=20value=201-=EC=9D=B8=EC=9E=90=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95,=20Subscribed=20=EC=98=A4=EC=97=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/README.md | 7 +- .../archive/canexecute-inst-arg-reversed.md | 102 +++++++ .claude/audit/gcconn-trick-verification.md | 85 ++++-- .claude/base/architecture.md | 2 +- .claude/base/bind-system-plan.md | 157 +++++----- .claude/base/dispatch-core-plan.md | 21 +- .claude/base/effect-plan.md | 30 +- .claude/base/lifecycle-pattern.md | 269 ++++++++++++++---- .claude/base/relate-plan.md | 47 +++ .claude/base/slot-plan.md | 22 +- .claude/base/store-semantics.md | 4 +- .claude/luau-test/README.md | 43 ++- .claude/luau-test/STATUS.md | 30 +- .../03-recursive-store-bind-dispatch.luau | 3 +- .../10-roblox-studio-checks.server.luau | 15 + .claude/question.md | 14 +- .claude/research/pre-implementation-audit.md | 7 +- ...-14-04-processedpreref-postref-symmetry.md | 112 ++++++++ .../2026-08-14-05-canexecute-value-scoped.md | 187 ++++++++++++ CLAUDE.md | 74 ++++- HUMAN_TODO.md | 18 +- ROADMAP.md | 88 ++++-- 22 files changed, 1098 insertions(+), 239 deletions(-) create mode 100644 .claude/archive/canexecute-inst-arg-reversed.md rename .claude/luau-test/{not-run => rewrite-required}/10-roblox-studio-checks.server.luau (87%) create mode 100644 .claude/session/2026-08-14-04-processedpreref-postref-symmetry.md create mode 100644 .claude/session/2026-08-14-05-canexecute-value-scoped.md diff --git a/.claude/README.md b/.claude/README.md index 21f5e4c..b925c6f 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -16,7 +16,7 @@ | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | -| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 3개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만, A-1/A-2/B/C는 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/` 44개. 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨) | +| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 3개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션]** 실측된 사실 자체는 그대로 유효하고 `canExecute(value)` 1-인자 재정정으로 오히려 더 중요해졌으나, 인용하던 `canBound`가 폐기돼 미확인 항목 목록을 새 모델 기준으로 갱신함 — 이중 바인딩 게이트/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/` 44개. 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨) | | `tools/` | **[2026-08-13 아홉 번째 세션 신설]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | | `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, CLAUDE.md의 "세션 히스토리" 절에서 각 항목이 여기로 링크. 항상 읽을 필요 없음 — 결정의 논의 과정이 궁금할 때만 | | `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -31,7 +31,7 @@ |---|---| | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 44개로 확정). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드/`store.key` type function 한계도 여기 통합, 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` | -| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | +| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), 별도 `canBound`는 폐기되어 `canExecute` 하나로 통합, gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md` | | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | | `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md` | | `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md` | @@ -96,9 +96,10 @@ | `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | | `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 | -| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | +| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(→2026-08-14 다섯 번째 세션에 폐기, `canExecute`로 통합)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | | `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | +| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` | ## 참고 diff --git a/.claude/archive/canexecute-inst-arg-reversed.md b/.claude/archive/canexecute-inst-arg-reversed.md new file mode 100644 index 0000000..907108d --- /dev/null +++ b/.claude/archive/canexecute-inst-arg-reversed.md @@ -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` 전체의 전제), 같은 세션에 확정돼 같은 + 절에 반영됨. diff --git a/.claude/audit/gcconn-trick-verification.md b/.claude/audit/gcconn-trick-verification.md index 0e68e55..97659c8 100644 --- a/.claude/audit/gcconn-trick-verification.md +++ b/.claude/audit/gcconn-trick-verification.md @@ -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. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 98a8c9c..16c803f 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -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 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 1594a86..0ecf0d0 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -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 하나로 통일"). diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 8540e31..19c52fe 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -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 절의 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 951633d..fa18f84 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -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 부착 포함, diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index fdc251d..9019747 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -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`가 들어오면 이전 것에 걸어둔 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` 직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지" diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index 5b7fb72..d102c21 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -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행이 말하는 "값이 자기 키를 다시 참조하는" 자기참조(예: diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index b5975ba..a6ce07e 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -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/ diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 01ad4db..ffdcdac 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -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 온톨로지" 절 참고. diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 602792a..d2e53a4 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -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`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `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<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (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**로 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 26db0b7..73b2464 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -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건) diff --git a/.claude/luau-test/done/03-recursive-store-bind-dispatch.luau b/.claude/luau-test/done/03-recursive-store-bind-dispatch.luau index 87f3943..2363fad 100644 --- a/.claude/luau-test/done/03-recursive-store-bind-dispatch.luau +++ b/.claude/luau-test/done/03-recursive-store-bind-dispatch.luau @@ -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 대신 저 조합을 쓸 것. diff --git a/.claude/luau-test/not-run/10-roblox-studio-checks.server.luau b/.claude/luau-test/rewrite-required/10-roblox-studio-checks.server.luau similarity index 87% rename from .claude/luau-test/not-run/10-roblox-studio-checks.server.luau rename to .claude/luau-test/rewrite-required/10-roblox-studio-checks.server.luau index 93a5d5c..1f454ed 100644 --- a/.claude/luau-test/not-run/10-roblox-studio-checks.server.luau +++ b/.claude/luau-test/rewrite-required/10-roblox-studio-checks.server.luau @@ -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가 필요함): diff --git a/.claude/question.md b/.claude/question.md index 9b56e9f..b0a961f 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -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`이거나 같은 핸들러가 곧 처리할 새 값**임이 계약으로 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 55c026d..af2f5ef 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -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 실측 필요" 캐비엇으로 이동). diff --git a/.claude/session/2026-08-14-04-processedpreref-postref-symmetry.md b/.claude/session/2026-08-14-04-processedpreref-postref-symmetry.md new file mode 100644 index 0000000..73cbb34 --- /dev/null +++ b/.claude/session/2026-08-14-04-processedpreref-postref-symmetry.md @@ -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`는 +여전히 백로그 상태 그대로(우선순위·"착수 여부 미정" 변경 없음) — 착수 +시점에 이번에 정리된 대칭 설계를 그대로 가져다 쓰면 됨. diff --git a/.claude/session/2026-08-14-05-canexecute-value-scoped.md b/.claude/session/2026-08-14-05-canexecute-value-scoped.md new file mode 100644 index 0000000..9445ece --- /dev/null +++ b/.claude/session/2026-08-14-05-canexecute-value-scoped.md @@ -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순위 항목은 +이름 논의라 그대로 열려 있음(시그니처는 이번에 확정). diff --git a/CLAUDE.md b/CLAUDE.md index d5c32fa..48a748f 100644 --- a/CLAUDE.md +++ b/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` 문제가 애초에 안 생기는 이유와 같은 +뿌리임을 확인. `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`. diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index 1fd077e..3e0e71f 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.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 솔버 설정 확인 diff --git a/ROADMAP.md b/ROADMAP.md index cf6343a..cb499b6 100644 --- a/ROADMAP.md +++ b/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)`/ `Dispatch.setOffsetSource(inst,i,offset:Source|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`의 각 필드를 `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 — 컴포넌트 합성 레이어