quad/.claude/session/2026-08-12-11-retract-always-fires-correction.md
qwreey f20922ce39
decide(bind-system): retract fires on every re-dispatch, not just type changes
Corrects a foundational error: "handler type unchanged -> retract skipped,
process diffs" was never actually true. StoreBind unconditionally calls
Dispatch.retractUnder before every re-dispatch (already stated in the
original dispatch model doc) -- Tag's old assert(v==nil) was taken at
face value and the general rule was wrongly back-derived from it.

Tag, Ref, Slot, and Attribute (the last three designed earlier this same
session) all inherited the flawed premise. Fixes across all four:

- Tag: rewritten around kTagMap/tagNameMap reference counting so
  AddTag is entirely process's job, RemoveTag entirely retract's,
  using a Contains hint to skip engine calls that would be immediately
  redone. Also fixes the case where two independent Tag(...) values at
  different positions share a name (union semantics, like className).
- Ref/Slot: drop the incorrect assert(v==nil), split into "retract
  unbinds/destroys the old, process binds/mounts the new" with an
  identity check on both sides to avoid spurious re-fire.
- Attribute: AttributeKeyHandler.retract gated on v==nil so ordinary
  value updates don't flicker the attribute to nil; the group's
  "unchanged name" delegation now also retracts before reprocessing,
  since skipping it was silently stacking chain entries every cycle.

Reversal preserved in archive/retract-always-fires-reversed.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 16:13:25 +09:00

6.1 KiB

2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, Tag 참조 카운트 재설계

배경

열 번째 세션(Attribute 이름 소유권)에 이어, 사용자가 같은 클래스의 문제가 Tag에도 있을 수 있다고 지적: 서로 다른 배열 위치의 Tag(...)가 같은 이름을 겹쳐 가질 수 있는데(Frame { Tag("a"), Tag("a","b") }, 웹 className="a a a"와 같은 합집합 시맨틱), 한 위치의 diff만으로 다른 위치가 아직 쓰는 이름을 지워버리는 참조 카운트 버그가 날 수 있다는 문제 제기.

논의 전개 — 근본 오류 발견까지

  1. Claude가 처음 위치별 {[k]: true} set(참조 카운트)을 제안 → 사용자 동의.
  2. 사용자가 후속 질문: "retract에서 새롭게 들어온 v가 Tag라면 Contains를 확인해서 재사용될 거면 RemoveTag 안 함" — 이게 성립하려면 retractv=Tag(nil 아닌 값)를 받는 경우가 있어야 함. Claude가 처음엔 이걸 기존 assert(v==nil)과 모순된다고 판단해 "메커니즘을 바꾸자는 거냐"고 되물음.
  3. 사용자가 재확인: "정확히 저는 어떤 값이든 덮여 쓰여지는 즉시 retract를 실행하는 거로 두기로 했었어요 — 전체 process 트랙을 retract하고 리빌드한다는 맥락이었어요."
  4. Claude가 bind-system-plan.md의 "확정된 디스패치 모델" 절(2026-08-04 원문)을 재확인 — Dispatch/StoreBind.luau는 재-dispatch 전에 무조건 Dispatch.retractUnder를 부른다고 이미 명시돼 있었음. "핸들러 타입이 안 바뀌면 생략"이라는 조건은 어디에도 없었음 — 2026-08-07 여덟 번째 세션에 추가된 "retract가 의미 있는 유일한 패턴" 서술이 자기 문서 다른 절과 처음부터 모순돼 있었고, Tagassert(v==nil)을 액면 그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 실제 출처였음이 드러남.

파급 효과 확인

이 오류가 이번 대화에서 만든 Ref(여덟 번째 세션)/Slot(아홉 번째 세션)/Attribute(열 번째 세션) 설계 전부에 그대로 이어받아져 있었음 — 셋 다 assert(v==nil)을 두고 "같은 핸들러 타입이면 process가 diff"라는 전제로 설계돼 있었기 때문. 사용자의 지적("retract는 v가 다른 값일 수 있는데, diff하는 모든 곳에서 is로 잘 테스트하고 있는지 봐야할듯")대로 전수 감사·수정 진행.

정정된 일반 원칙

retract(inst,k,v)는 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상 불림 — vnil일 수도 대체하는 새 값 자체일 수도 있음, retract 안에서 v==nil을 가정하면 안 됨. 대부분의 핸들러(PropertyHandler, NoneHandler, UICorner 숏핸드)는 이 반복 호출에서 실제로 할 일이 없어 no-op이면 충분 — "타입 안 바뀌면 아예 안 불린다"가 아니라 "불리지만 몸체가 비어 있어도 된다"가 정확한 표현. 여러 위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를 공유하는 핸들러는 retract가 이전 기여 제거(엔진 호출은 v 힌트로 skip 가능), process가 새 기여 등록을 전담하는 분업이 자연히 나옴 — process 쪽에 별도 old-vs-new diff가 더 이상 필요 없어짐(그 일을 retract가 매번 정확히 대신 해줌).

각 파일 정정 내역

  • base/tag-plan.md: TagHandler 메커니즘 전면 재작성 — 단일 relate(이름 집합)를 kTagMap(위치→Tag)+tagNameMap(이름→Tag set) 두 릴레이션으로 분리. retract가 이전 Tag의 이름들을 무조건 tagNameMap에서 빼되, 실제 RemoveTag는 "새로 들어올 Tag가 그 이름을 여전히 Contains하는가" 힌트로 skip. process는 새 Tag의 이름을 무조건 등록(집합이 비어있던 경우만 실제 AddTag) — 자기 diff 불필요. AddTag는 온전히 process, RemoveTag는 온전히 retract로 완전히 분업. 여러 위치가 같은 이름을 겹쳐 가지는 경우도 공유 tagNameMap 집합으로 자동 해결.
  • base/bind-system-plan.md: 일반 retract 계약 절 전면 재작성(위 "정정된 일반 원칙" 그대로). Ref의 retract 절 — assert(v==nil) 제거, "언바인딩은 retract 전담, 바인딩은 process 전담"으로 재설계(둘 다 old==v/old~=v identity 체크로 spurious 재발행 시 콜백 이중 발화 방지). NoneHandler 절의 "retract와 무관" 근거도 정정(불리긴 하지만 할 일이 없어서 no-op).
  • base/slot-plan.md: "Slot과 Store 바인드의 관계" 절 — destroySlotTree 호출이 process에서 retract로 이동(old~=v면 폐기), process는 마운트만 전담(old==slotValue면 no-op).
  • base/attribute-plan.md: AttributeKeyHandler.retractv==nil 가드 추가(그렇지 않으면 매 store 재발행마다 attribute가 잠깐 nil로 flicker). 그룹의 "남아있는 이름" 위임도 retractUnder 없이 Dispatch.process만 반복 호출하면 체인이 매번 새 항목을 쌓기만 해서(팝은 retractUnder의 일) 누적 누수가 생기는 걸 발견 — 남아있는 이름도 먼저 retractUnder(같은 캐싱된 키로) 부른 뒤에만 process하도록 정정.
  • base/ui-shorthand-plan.md, base/tween-plan.md: 결론(해당 핸들러의 retract는 no-op)은 안 바뀌었으나 "retract가 아예 안 불린다"는 근거 서술만 정정.
  • archive/retract-always-fires-reversed.md 신설 — 원문·역전 이유·영향받은 문서 전부 보존.
  • README.md — 위 파일들 요약 라인 + 아카이브 표에 새 항목 추가.

남은 것

전수 감사는 base/ 전체 retract 언급 파일(관련 없는 relate-plan.md/ module-lifecycle-plan.md/lifecycle-pattern.md/onchange-plan.md 등)까지 훑었으나, onchange-plan.mdOnChangeHandler(Connection disconnect/reconnect 패턴)는 애초에 공유 리소스가 없어 이 문제에 해당 안 됨 — 정정 불필요 확인만 하고 넘어감.