quad/quad-base/src/Effect.luau
qwreey-agent-selene e0cb530b6b
feat: round11 §4 전량 종결(회신 3) — H-198 재시작 모델, H-186 UB, H-205 level 3, H-208/H-209/H-211
- H-198(사용자 안): 상류 스탬프(dep:_track)를 fn 직전으로 + Get을 수렴까지의
  재시작 루프로 — 계약 강화: 모든 Get이 fn 도중 변경(재진입·게이트 유보)을
  같은 호출 안에서 수렴시켜 항상 최신 반환. 매 패스 자기 dep Set은 UB.
  state-epoch-plan §4 세 절 + H-85 bullet 정정, spec.state 6 새 계약,
  spec.gate 10(리뷰의 영구 stale 재현 → 같은 Get에서 99 + flush 통지만)
- H-186(b): 교차 인스턴스 값 혼용 UB 문서화 — architecture 13번 + content-map
  §4 22번(H-116 이웃), 코드 주석. M5 재검토
- H-205(a): Modifier 가드 level 2→3(직접 Get이면 유저 호출부), spec.state 12 단언
- H-208: Ref:Set 스냅샷을 table.clone + 집합 병합으로(사용자 — 더 싸고 dedup 공짜)
- H-209: src 전 파일 pairs/ipairs → generalized iteration(사용자 — 최적화로 더
  빠름; 메타테이블 있는 테이블의 raw 순회 실측 확인). 문서 의사코드 표기는
  H-178과 같은 급으로 무변경
- H-211: Relate:SetWeak의 캐스트 없는 setmetatable 대입이 IDE(strict)에서
  TypeError(플레인 luau-analyze는 솔버 차로 조용) → 로컬 주석 + :: any 경유
- §4 열린 문항 0, 코드 마커 0. 감사 2라운드 수렴(확실 1·의심 1 → 0)

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
2026-08-31 13:33:09 +09:00

304 lines
12 KiB
Text

--[[
Effect — a leaf consumer with several dependencies (`State`/`Source`/`Ref`)
and a cleanup contract. `Effect(fn, ...deps)`; `fn(self) -> ...cleanup`.
`.claude/base/effect-plan.md`, transcribed as-is:
- "확정 구조": the strong owner is ALWAYS the Effect (`_deps` map);
registrations at the deps are weak (`ref:WeakCallback`, internal
`observer:WeakSubscribe()`); the single fire gate is `canExecute`.
- "의사코드 — 생성자": deps validated once (`select("#")`, nil → error,
non State/Source/Ref → error, duplicates ignored — `H-70`); one
closure PER DEP KIND (`onRefFire(_, ref)` / `onStateFire(_, _, from)`,
`H-107`); `fire`: `from == nil` (the internal Observer's install fire)
is dropped, else `_epochs:Update(from)` → `Rerun` (`H-151`: the only
place `_epochs` is written; no `_blocker`, `H-150`); `_epochs` seeded
`Sync`/`TrackFrom` by `isEpoch`; install = `_rerunRequired = true` +
`rawRerun(self, true)`.
- `_bindDestroying(inst)` (guard `isRunning`; `_unbindDestroying` first;
injected `onDestroying(inst, fn)`; replay once if `_rerunRequired`),
`_unbindDestroying()` (idempotent), `_consumeCleanup()` (read → clear →
`_rerunRequired = true` → run under `_cleanupRunning`, `H-160`).
- `rawRerun(self, force)` + public `Rerun()`: re-entrancy is deferred
(`_pending`), an unexecutable state HOLDS the request (`H-159`), the
loop has no death judgement (`H-147`); errors leave `_running` /
`_cleanupRunning` set — that Effect is dead, by contract.
- "`EffectHandle:Subscribe()`": four entry points of its OWN (not
Observer's bodies, `H-144`), first line `isRunning` guard (`H-147`),
`resubscribeTail` replays a held change after registration,
`Unsubscribe` consumes the cleanup.
Assembly (`H-174`): `Effect.Init(module)` — gates (`module.canExecute` /
`module.canBound`) and the injected `module.onDestroying` are read at call
time; the registries come from `Observer.luau` (`H-99`), pulled in with
`module:RunInit(Observer.Init)` (idempotent).
]]
local Brand = require("./Brand")
local EpochMap = require("./EpochMap")
local ImplRegistry = require("./ImplRegistry")
local Observer = require("./Observer")
local EffectBrand = Brand.EffectBrand
local isState = Brand.isState
local isSource = Brand.isSource
local isRef = Brand.isRef
local isEpoch = Brand.isEpoch
-- One implementation per quad instance (`H-174`); storage scheme in `ImplRegistry.luau`.
local implsOf = ImplRegistry.implsOf
local function createImpl(module: any)
module:RunInit(Observer.Init) -- registries live there (`H-99`)
local ObserverImpl = Observer.implFor(module)
local Subscribed = ObserverImpl._Subscribed
local WeakSubscribed = ObserverImpl._WeakSubscribed
local Impl = {}
Impl.__index = Impl
-- ── local helpers, declared before their callers ─────────────────
local function isRunning(self: any): boolean -- predicate only; `error` stays in each body (level 2)
return self._running == true or self._cleanupRunning == true
end
-- The body. `force` means exactly one thing: "may be called ignoring
-- canExecute" — the install from the constructor, not yet bound.
local function rawRerun(self: any, force: boolean)
if self._running then
self._pending = true -- re-entered while running → deferred re-run
return
end
if self._cleanupRunning or self._dying or (not force and not module.canExecute(self)) then
self._rerunRequired = true -- `H-159`/`H-160`/`H-182`: unexecutable (cleanup running / dying / unbound / dead)
return -- → HOLD, replayed once on the next bind/subscribe
end
self._running = true
repeat
self._pending = false
self:_consumeCleanup() -- sets `_rerunRequired = true`
self._rerunRequired = false -- the ONLY place the flag goes down: fn really runs
-- `H-185` (user, 2026-08-31): the cleanup is ONE function — wrap several
-- teardowns in one closure (`function() a() b() end`); extra returns are
-- deliberately not collected (a list would widen the re-entry surface).
self._cleanup = self.fn(self)
until not self._pending
self._running = false
end
-- After registration: replay a held change once (`H-144`; `Refresh` gone, `H-151`).
local function resubscribeTail(self: any)
if self._rerunRequired then
self:Rerun() -- registered now, so the public gate passes
end
end
-- ── hooks called by `bindLifetime` / `unbindLifetime` ─────────────
-- `H-184`: `bindLifetime` asks this BEFORE committing the binding, so a refusal
-- cannot leave a half-bound handle (bound, `canExecute` true, no Destroying
-- connection). Level 3: past this method and `bindLifetime` to the user's call.
function Impl._assertBindable(self: any)
if isRunning(self) then
error("Effect: cannot bind an Effect from inside its own fn or cleanup", 3) -- `H-147` (A)
end
end
function Impl._bindDestroying(self: any, inst: any)
-- `H-147`'s guard moved to `_assertBindable` (`H-184`) — `bindLifetime` is the
-- only caller and has already asked it before committing the binding.
self:_unbindDestroying() -- rebind (portal remount): drop the old connection first — idempotent
self._dying = false -- `H-182`: rebinding a survivor of a Destroy wave re-arms it
-- (1) leaf dies → cleanup exactly once. The one hook point (`LP-2`).
self._destroyConn = module.onDestroying(inst, function() -- injected op, read at call time
-- `H-182`: `canExecute` stays true for the rest of the Destroy wave (gcconn
-- is cut last) — the flag closes that window so a dep change later in the
-- wave HOLDS instead of re-running fn on the dying leaf. NOT `_destroyed`
-- (Slot's word): a Slot never rebinds, this handle may — the bind above re-arms.
self._dying = true
self:_unbindDestroying()
self:_consumeCleanup()
end)
-- (2) catch-up: a change held while unexecutable is replayed once. gcconn is
-- connected by now, so the public Rerun's gate passes.
if self._rerunRequired then
self:Rerun()
end
end
function Impl._unbindDestroying(self: any)
if self._destroyConn then
self._destroyConn:Disconnect()
self._destroyConn = nil
end
-- Ref callbacks and the internal Observers stay (weakly registered; the gate
-- silences them). `_cleanup` is NOT run here.
end
-- read → clear → run. `_cleanup`'s presence is not "installed" — the flag is.
function Impl._consumeCleanup(self: any)
local c: any = self._cleanup
self._cleanup = nil
self._rerunRequired = true -- consumed = must install again at the next chance
if c ~= nil then
self._cleanupRunning = true -- `H-160`: cleanup runs outside `_running` in two of its three sites
(c :: () -> ())()
self._cleanupRunning = false
end
end
function Impl.Rerun(self: any): any -- public, no args — always gated
rawRerun(self, false)
return self
end
-- ── four entry points — EffectHandle's OWN bodies (`H-144` (b)) ────
function Impl.WeakSubscribe(self: any): any
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if not module.canBound(self) then
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
end
self.Subscribed = true
self._dying = false -- `H-182`: every path that re-arms executability lowers it
WeakSubscribed[self] = true
resubscribeTail(self)
return self
end
function Impl.Subscribe(self: any): any
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if not module.canBound(self) then
error(if self.Subscribed then "Effect: already subscribed" else "Effect: already bound to an Instance", 2)
end
self.Subscribed = true
self._dying = false -- `H-182`
WeakSubscribed[self] = true
Subscribed[self] = true -- the strong keep is up BEFORE the tail runs
resubscribeTail(self)
return self
end
function Impl.WeakUnsubscribe(self: any): any -- lenient (`H-133`) — does not touch the cleanup
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if Subscribed[self] ~= nil then
error("Effect: subscribed strongly; use :Unsubscribe()", 2)
end
WeakSubscribed[self] = nil
self.Subscribed = false
return self
end
function Impl.Unsubscribe(self: any): any
if isRunning(self) then
error("Effect: cannot change subscription from inside fn or cleanup", 2) -- `H-147`
end
if Subscribed[self] == nil then -- gate FIRST: never strongly subscribed → error, cleanup untouched
error("Effect: not subscribed strongly; use :WeakUnsubscribe()", 2)
end
Subscribed[self] = nil
WeakSubscribed[self] = nil
self.Subscribed = false -- blocks future re-runs
self:_consumeCleanup() -- only when the gate passed: the last cleanup exactly once
return self
end
-- ── constructor ──────────────────────────────────────────────────
local function Effect(fn: any, ...: any): any
if type(fn) ~= "function" then
error("Effect: fn must be a function", 2)
end
local self = setmetatable({
fn = fn,
_deps = {}, -- strong owner of every registration
_epochs = EpochMap(),
_cleanup = nil,
_rerunRequired = false,
_running = false,
_pending = false,
_cleanupRunning = false,
_dying = false, -- `H-182`: inside a Destroy wave, after our Destroying ran
_destroyConn = nil,
Subscribed = false,
}, Impl)
EffectBrand:register(self)
-- (0) deps validation — once, in the constructor. `select("#")` so a nil hole
-- does not silently truncate the list.
-- `H-186` (user-confirmed 2026-08-31): a dep from ANOTHER quad instance is UB —
-- deliberately not checked (a guard here closes only half the hole: a foreign
-- `bindLifetime` on our handle is unreachable from base). Documented, revisit at M5.
local seen = {}
for i = 1, select("#", ...) do
local d = (select(i, ...))
if d == nil then
error(`Effect: dep #{i} is nil`, 2)
end
if not (isState(d) or isSource(d) or isRef(d)) then
error(`Effect: dep #{i} is not a State/Source/Ref`, 2)
end
seen[d] = true -- duplicates are silently ignored (dedup lives in `_deps`/`_epochs`)
end
-- (1) register deps — HERE, ONCE. One closure per dep kind (`H-107`).
local function fire(from: any)
if from == nil then
return -- the internal Observer's install fire (no source) cannot `Update(nil)`
end
if self._epochs:Update(from) then -- `H-151`: the only place `_epochs` is written
self:Rerun() -- unexecutable → rawRerun holds it (`H-159`)
end
end
local function onRefFire(_value: any, ref: any)
fire(ref) -- Ref: the 2nd arg is the source
end
local function onStateFire(_targetState: any, _observer: any, from: any)
fire(from) -- Observer: the 3rd arg is the source
end
for d in seen do
if isRef(d) then
self._deps[d] = onRefFire -- strong owner = Effect
d:WeakCallback(onRefFire) -- the Ref side is weak
else
local o = d:Observer(onStateFire)
self._deps[d] = o -- strong owner = Effect
o:WeakSubscribe() -- the global registry side is weak
end
-- seeding by "is it an Epoch" — Source/Ref are (`Sync`), a State hands over
-- what it tracks (`_track` → `TrackFrom`, same polymorphic hook `_recompute` uses)
if isEpoch(d) then
self._epochs:Sync(d)
else
d:_track(self._epochs)
end
end
-- (2) install — once, immediately; cannot be deferred to bind.
self._rerunRequired = true
rawRerun(self, true)
return self
end
module.Effect = Effect
return Impl
end
local function Init(module: any)
implsOf(module).Effect = createImpl(module)
end
local function implFor(module: any)
local impl = implsOf(module).Effect
if impl == nil then
error("Effect: Effect.Init(module) has not run for this quad instance", 1)
end
return impl
end
return {
Init = Init,
implFor = implFor,
}