qa: Observer:_catchUp() — 캐치업 발화 네 곳 단일화 (사용자 확정), Slot 꼬리는 bindLifetime의 _catchUp에 위임

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
This commit is contained in:
qwreey 2026-08-28 15:55:51 +09:00
parent 5e96cd6258
commit ab952eea24
Signed by: qwreey
GPG key ID: D28DB79297A214BD
4 changed files with 33 additions and 21 deletions

View file

@ -352,14 +352,14 @@ function bindLifetime(inst, value)
-- 순회에서 죽고, 피해 가도 **바인드마다 `Rerun`이 도는 `H-58`
-- 되살아난다.** 발화 게이팅은 전부 `canExecute(handle)` 하나가 맡는다 —
-- `base/effect-plan.md`의 "확정 구조" 절이 소스.
if isObserver(value) and value._rerunRequired then
if isObserver(value) then
-- ⭐ [2026-08-28 `H-159`] Observer도 대칭 — 묶이기 전(생성~바인드 사이)에 온 emit은
-- Observer 자신의 `_receive``_rerunRequired`로 홀드해 두고(`source-state-plan.md`의
-- `Observer:_receive` — 전파 루프는 `EmitReceive`로만 본다),
-- 묶이는 순간 1회 발화(출처 없음 — 설치 발화와 같은 모양). Observer엔 epoch가
-- 없으니 dedup은 없고 "놓친 게 있었다"만 기록된다.
value._rerunRequired = false
value.fn(value._state, value, nil)
-- 없으니 dedup은 없고 "놓친 게 있었다"만 기록된다. 본문은 `Observer:_catchUp`
-- (같은 문서) — `Subscribe`/`WeakSubscribe`와 같은 한 곳.
value:_catchUp()
end
if isEffect(value) then
value:_bindDestroying(inst) -- Destroying 연결 + **홀드된 변경 캐치업 1회**
@ -483,10 +483,7 @@ function Observer:WeakSubscribe()
end
self.Subscribed = true -- ⭐ [H-111] 약한 쪽도 세운다 — 구독 경로 공용 플래그
WeakSubscribed[self] = true
if self._rerunRequired then -- [2026-08-28 `H-159`] 구독 전에 홀드된 변경 1회(바인드와 대칭)
self._rerunRequired = false
self.fn(self._state, self, nil)
end
self:_catchUp() -- [2026-08-28 `H-159`] 구독 전에 홀드된 변경 1회(바인드와 대칭)
return self
end
@ -524,10 +521,7 @@ function Observer:Subscribe()
self.Subscribed = true
WeakSubscribed[self] = true
Subscribed[self] = true -- 강한 킵 하나만 더
if self._rerunRequired then -- [2026-08-28 `H-159`] 위 `WeakSubscribe`와 같은 꼬리
self._rerunRequired = false
self.fn(self._state, self, nil)
end
self:_catchUp() -- [2026-08-28 `H-159`] 위 `WeakSubscribe`와 같은 꼬리
return self
end
@ -620,7 +614,8 @@ emitFrom)`), `_state`(리시버 State — `_hold`로 강참조, `source-state-pl
`WeakSubscribe`가 1회 발화. Effect와 같은 뜻("`fn`이 돌아야 하는데 아직 안 돌았다"):
생성 시 참 → `state:Observer(fn)` 생성자의 "등록 시점 즉시 1회 실행"이 돌면서 거짓 →
그 뒤 묶이기 전 사이에 온 변경이 다시 세운다), **`_receive(from)`**(`EmitReceive` —
`source-state-plan.md``_emitDown` 아래). 레지스트리 두 테이블은 인스턴스 필드가 아니라
`source-state-plan.md``_emitDown` 아래), **`_catchUp()`**(홀드가 있었으면 출처 없이 1회 —
`bindLifetime`·`Subscribe`·`WeakSubscribe`가 부름, 내부 메소드). 레지스트리 두 테이블은 인스턴스 필드가 아니라
`Observer.luau`의 모듈 로컬. Effect와 달리 epoch 맵·cleanup·재진입 플래그는 없다.
**여전히 참인 것**: 자기 짝은 반드시 같이 지운다 — `:Unsubscribe()`

View file

@ -2431,19 +2431,15 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
-- **트리가 확정된 여기서**`activateList` 재마운트 분기가 넘겨둔 일. 언마운트
-- 사이에 `data`가 바뀌었으면(`_receive`가 세운 `_rerunRequired`) 끄고 묶은 뒤
-- reconcile을 **정확히 1회** — 상류 `data`는 epoch가 최신이라 `Get` 한 번이면 된다.
-- `bindLifetime` 자신의 홀드 발화에 맡기지 않는 이유: 그건 gcconn 연결 직후
-- 묶이는 자리에서 돌아 순서를 이 꼬리에 못 맞춘다(`_baseObserver`는 위에서 끄고
-- 묶었다 — 그쪽 캐치업은 `setOffsetSource` 경로가 한다).
-- bind 자체가 이 꼬리에서 일어나므로 `bindLifetime``Observer:_catchUp()`이 그
-- 1회를 맡는다 — 별도 끄기/호출 없음(`_baseObserver`는 위에서 끄고 묶었다 — 그쪽
-- 캐치업은 `setOffsetSource` 경로가 한다).
-- ⚠️ **재마운트일 때만** — 최초 population은 위 `activateList`의 fresh 경로가 이미
-- observer를 만들고 묶었다(감사 3라운드: 여기서 또 묶으면 `canBound` error). 늦은
-- `:List()`(이미 마운트된 Slot에 설치, `Slot:List`)는 이 함수를 안 거치므로 fresh
-- 경로의 bind가 유일 — 그래서 fresh 경로의 bind를 여기로 옮길 수도 없다.
if remountingList and slot._listObserver then
local listObserver = slot._listObserver
local held = listObserver._rerunRequired
listObserver._rerunRequired = false
bindLifetime(physicalTarget, listObserver)
if held then listObserver.fn(slot._listData, listObserver, nil) end -- == reconcile(data:Get())
bindLifetime(physicalTarget, slot._listObserver) -- 안의 `_catchUp()`이 홀드 시 reconcile(data:Get()) 1회
end
-- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에

View file

@ -272,6 +272,17 @@ function Observer:_receive(from)
end -- (Effect의 내부 Observer도 이 경로 — `fire``fn`이다)
end
-- ⭐ [2026-08-28 확정] 캐치업 — 묶이거나 구독될 때 홀드가 있었으면 1회. 출처 없음(`nil`)이라
-- `_receive`가 아니라 별도 내부 메소드(사용자: *"observer 계열의 rerun 느낌이네. 외부적으로
-- 쓸 일은 안 보여서"* — 공개 표면 아님). `bindLifetime`·`Subscribe`·`WeakSubscribe`가
-- 부르는 유일한 자리 — 네 곳이 각자 세 줄을 반복하던 것을 여기로(감사 4라운드 지적).
function Observer:_catchUp()
if self._rerunRequired then
self._rerunRequired = false
self.fn(self._state, self, nil)
end
end
-- `EmitReceive` — 구독자 집합 `_subs`의 원소가 만족하는 인터페이스(`Epoch`처럼 구조적).
type EmitReceive = { _receive: (self: any, from: Epoch | EpochSet) -> () }
-- 구현: `ComputeNode`/`GateNode`(state-epoch-plan.md §4 규칙 1~3 / gate-plan.md 조립 절), `Observer`(위).

View file

@ -398,3 +398,13 @@ observer 측에서 해당 emit 을 처리하는 함수를 만들어주는게 맞
같은 뜻으로 **생성 시 참 → 설치 발화가 내림 → 묶이기 전 변경이 다시 세움**(사용자
확인: *"설치 발화 이후엔 rerunRequired = false 되긴 해."*). 반영: `source-state-plan.md`
1171행 계약 문구.
## 캐치업 발화의 단일화 — `Observer:_catchUp()` (감사 4라운드 → 사용자 확정)
`bindLifetime`·`Observer:Subscribe`·`WeakSubscribe`·`materializeSlotTree` 꼬리 넷이
"`_rerunRequired` 끄고 → `fn(state, self, nil)`"을 각자 반복하던 것을 내부 메소드
하나로. **사용자**: *"a 나쁘지 않은것 같아. observer 계열의 rerun 느낌이네. 외부적으로
쓸 일은 안 보여서, 그대로 구현하면 될것 같아."* `_receive(from)`(출처 있는 통지)와
짝. Slot 꼬리는 bind 자체가 확정 뒤라 `bindLifetime` 안의 `_catchUp`이 reconcile 1회를
맡는다 — 별도 끄기/호출 제거. 반영: `source-state-plan.md`(정의), `lifecycle-pattern.md`
셋 + 필드 목록, `slot-plan.md` 꼬리.