- H-182 (a): Effect `_dying` — Destroying 콜백이 세우고 재바인드·Subscribe류가 내림, rawRerun 홀드 조건 합류(Slot `_destroyed`와 다른 이름은 의도 — 재바인드 가능) - H-183 (a): Observer `_running` — 모든 fn 실행 둘레 + 네 진입점 첫 줄 가드 (H-147 대칭; error 시 잔류는 설계상 인정) - H-184 (a): `_assertBindable` 훅 — bindLifetime이 부기 커밋 전에 문의(level 3), H-147 가드는 _bindDestroying에서 이 훅으로 이동. mock + lifecycle-pattern (1) - H-185: 권고 기각 — cleanup은 하나만(목록 소진은 표면 확대), 문서·타입 주석 명시 - H-187 (a): 타입 별칭 이름 넷 승인(quad-types-plan 기록, 마커 해소) - H-200 (b): Gate 생성이 setup 동안 상류 _subs에서 떼고 성공 후 재등록(pcall 없음) - H-203 (a): Blocker 순회가 핸들마다 IsBlocked 재확인 — 재차단 시 잔여는 다음 Off로 - H-168~H-170 재확인 반영: H-170 한계 공개 문서화(ref-plan + content-map 22번) - 스펙: spec.effect 10 / spec.observer 9 / spec.gate 1 확장 / spec.blocker 8 - 감사 3라운드(3+2 → 3 → 0 수렴): 필드 목록 _running/_dying, CLAUDE.md· project-context.md M2 배너(목록은 todos.md 00번 단일 소스), session-summary - 남은 코드 마커 셋: H-186/H-198(재질문 — §4 회신 2 블록에 메인 답변), H-205(보류) Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
192 lines
7.7 KiB
Text
192 lines
7.7 KiB
Text
--[[
|
|
Observer — the leaf subscriber a `State` fires, and the owner of the two
|
|
global subscription registries (`Subscribed` strong / `WeakSubscribed`
|
|
weak-key) that `Effect.luau` shares (`H-99`).
|
|
|
|
Assembly (`H-174`): `Observer.Init(module)` builds one implementation (and
|
|
one registry pair) per quad instance; the lifetime gates are read as
|
|
`module.canExecute(self)` / `module.canBound(self)` AT CALL TIME — never
|
|
captured at init, the backend overwrites those fields after `New()`.
|
|
`State.luau` reaches the constructor through `implFor(module)`.
|
|
|
|
Sources, transcribed as-is:
|
|
- `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드":
|
|
`Observer:_receive(from)` (`canExecute` → `fn(self._state, self, from)`,
|
|
else hold `_rerunRequired = true`, `H-159`), `Observer:_catchUp()`
|
|
(the ONLY place a held change is replayed — with `from = nil`),
|
|
`State:Observer(fn)` constructor ORDER (fn once → flag down → join
|
|
`_subs`; `H-159`/`H-164`), `observer._state` strong ref (`_hold`
|
|
equivalent, `H-110`); "`state:Observer(fn)`" section: fires once on
|
|
registration, `fn(targetState, self, emitFrom?)` — `nil` means
|
|
"no source: install or catch-up, read the value" (`H-164`); no-arg
|
|
`state:Observer()` = always-observe utility.
|
|
- `.claude/base/lifecycle-pattern.md` "(2) 전역 경로": the four entry
|
|
points — `WeakSubscribe` is the primitive (sets `.Subscribed = true`
|
|
too, `H-111`), `Subscribe` is INLINED (no delegation, `H-149`),
|
|
`WeakUnsubscribe` is lenient (`H-133`) except when a strong keep
|
|
exists, `Unsubscribe` is strict and clears both tables. Each entry
|
|
point runs its own `canBound` gate exactly once and throws with
|
|
`level 2` from its own body (`H-104`).
|
|
|
|
An Observer is NOT an `Epoch` and carries no epoch bookkeeping — it only
|
|
forwards the source it received. `EffectHandle` does NOT reuse these
|
|
bodies (heterogeneous types; `conventions.md` 설계 원칙).
|
|
]]
|
|
|
|
local Brand = require("./Brand")
|
|
local ImplRegistry = require("./ImplRegistry")
|
|
|
|
local ObserverBrand = Brand.ObserverBrand
|
|
|
|
local WEAK_KEY_MT = { __mode = "k" }
|
|
|
|
-- One implementation (and registry pair) per quad instance (`H-174`);
|
|
-- storage scheme in `ImplRegistry.luau`.
|
|
local implsOf = ImplRegistry.implsOf
|
|
|
|
local function createImpl(module: any)
|
|
local Impl = {}
|
|
Impl.__index = Impl
|
|
|
|
-- Registries — owned here, shared with `Effect.luau` (`H-99`). Per quad
|
|
-- instance because the gates that read `.Subscribed` are per instance.
|
|
local Subscribed = {} :: { [any]: true } -- strong: keeps the handle alive
|
|
local WeakSubscribed = setmetatable({}, WEAK_KEY_MT) :: { [any]: true } -- membership only
|
|
Impl._Subscribed = Subscribed
|
|
Impl._WeakSubscribed = WeakSubscribed
|
|
|
|
-- ── EmitReceive ──────────────────────────────────────────────────
|
|
function Impl._receive(self: any, from: any)
|
|
if module.canExecute(self) then -- read at fire time (`H-174`)
|
|
self._running = true -- `H-183`: fn may not change its own lifecycle (`H-147` symmetric);
|
|
self.fn(self._state, self, from) -- an fn that throws leaves it set — dead by contract
|
|
self._running = false
|
|
else
|
|
self._rerunRequired = true -- `H-159`: change before binding is held — replayed once when bound
|
|
end
|
|
end
|
|
|
|
-- Catch-up: bind/subscribe replays a held change once, with no source.
|
|
-- The only caller set: `bindLifetime`, `Subscribe`, `WeakSubscribe`.
|
|
function Impl._catchUp(self: any)
|
|
if self._rerunRequired then
|
|
self._rerunRequired = false
|
|
self._running = true -- `H-183`
|
|
self.fn(self._state, self, nil)
|
|
self._running = false
|
|
end
|
|
end
|
|
|
|
-- `H-184`: `bindLifetime` asks this BEFORE committing the binding — a refusal
|
|
-- must not leave a half-bound handle. Level 3: past this and `bindLifetime`.
|
|
function Impl._assertBindable(self: any)
|
|
if self._running then
|
|
error("Observer: cannot bind an Observer from inside its own fn", 3) -- `H-183`
|
|
end
|
|
end
|
|
|
|
-- ── four entry points (`lifecycle-pattern.md` (2)) ───────────────
|
|
function Impl.WeakSubscribe(self: any): any
|
|
if self._running then
|
|
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183` (`H-147` symmetric)
|
|
end
|
|
if not module.canBound(self) then -- same gate as bindLifetime (shared isBoundAlive)
|
|
error(
|
|
if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance",
|
|
2
|
|
)
|
|
end
|
|
self.Subscribed = true -- `H-111`: the weak path raises the flag too
|
|
WeakSubscribed[self] = true
|
|
self:_catchUp() -- `H-159`: held change once, symmetric with bind
|
|
return self
|
|
end
|
|
|
|
function Impl.WeakUnsubscribe(self: any): any
|
|
if self._running then
|
|
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183`
|
|
end
|
|
-- A strong keep left behind would make a half-released, never-GC'd handle: fail fast.
|
|
if Subscribed[self] ~= nil then
|
|
error("Observer: subscribed strongly; use :Unsubscribe()", 2)
|
|
end
|
|
-- `H-133`: that is the whole guard — never subscribed / already weakly released pass silently.
|
|
WeakSubscribed[self] = nil
|
|
self.Subscribed = false
|
|
return self
|
|
end
|
|
|
|
function Impl.Subscribe(self: any): any
|
|
if self._running then
|
|
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183`
|
|
end
|
|
-- `H-149`: NOT delegated to WeakSubscribe — level 2 must point at the user's call,
|
|
-- and colon delegation would resolve to a subtype's override.
|
|
if not module.canBound(self) then
|
|
error(
|
|
if self.Subscribed then "Observer: already subscribed" else "Observer: already bound to an Instance",
|
|
2
|
|
)
|
|
end
|
|
self.Subscribed = true
|
|
WeakSubscribed[self] = true
|
|
Subscribed[self] = true -- just one more layer: the strong keep
|
|
self:_catchUp()
|
|
return self
|
|
end
|
|
|
|
function Impl.Unsubscribe(self: any): any
|
|
if self._running then
|
|
error("Observer: cannot change subscription from inside its own fn", 2) -- `H-183`
|
|
end
|
|
-- Symmetric with WeakUnsubscribe's guard: release through the path you subscribed by.
|
|
if Subscribed[self] == nil then
|
|
error("Observer: not subscribed strongly; use :WeakUnsubscribe()", 2)
|
|
end
|
|
Subscribed[self] = nil
|
|
WeakSubscribed[self] = nil -- both tables, directly (`H-149`: no delegation)
|
|
self.Subscribed = false
|
|
return self
|
|
end
|
|
|
|
-- ── constructor — called by `State:Observer(fn)`; ORDER IS THE CONTRACT ──
|
|
local function alwaysObserve(targetState: any)
|
|
targetState:Get() -- no-arg `state:Observer()` = "always observe" utility
|
|
end
|
|
|
|
function Impl.new(state: any, fn: any): any
|
|
local o: any = setmetatable({
|
|
fn = fn or alwaysObserve,
|
|
_state = state, -- strong: the handle holds its upstream (`_hold` equivalent, `H-110`)
|
|
_rerunRequired = true,
|
|
_running = false, -- `H-183`: raised around every fn run; entry points refuse while up
|
|
Subscribed = false, -- the shared flag both handle types carry (`H-111`)
|
|
}, Impl)
|
|
ObserverBrand:register(o)
|
|
o._running = true -- `H-183`: the install fire is an fn run like any other
|
|
o.fn(state, o, nil) -- (1) fire once at registration — no source (`nil`)
|
|
o._running = false
|
|
o._rerunRequired = false -- (2) the install fire lowers the flag
|
|
state._subs[o] = true -- (3) only THEN join the subscriber set — reversed, (1) Setting its own
|
|
return o -- State would land in this Observer's `_receive` and raise the flag
|
|
end
|
|
|
|
return Impl
|
|
end
|
|
|
|
local function Init(module: any)
|
|
implsOf(module).Observer = createImpl(module)
|
|
end
|
|
|
|
local function implFor(module: any)
|
|
local impl = implsOf(module).Observer
|
|
if impl == nil then
|
|
error("Observer: Observer.Init(module) has not run for this quad instance", 1) -- invariant; callers pull it in first
|
|
end
|
|
return impl
|
|
end
|
|
|
|
return {
|
|
Init = Init,
|
|
implFor = implFor,
|
|
}
|