From 4169d7ba50c8c6dc21f20249a1a26bd63f3f5baf Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 28 Aug 2026 20:18:13 +0900 Subject: [PATCH] =?UTF-8?q?qa:=20=EB=8B=A8=EC=9C=84=201=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=ED=9A=8C=EC=8B=A0=20=EB=B0=98=EC=98=81=20=E2=80=94?= =?UTF-8?q?=20H-174=20=ED=8C=A9=ED=86=A0=EB=A6=AC=ED=98=95+module.canExecu?= =?UTF-8?q?te=20=EB=8A=A6=EA=B2=8C=20=EC=9D=BD=EA=B8=B0,=20H-169=20?= =?UTF-8?q?=EB=A6=AC=EB=B9=84=EC=A0=84=20=EA=B0=80=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=A7=84=EC=9E=85=20=ED=8C=8C=EB=8F=99=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0,=20H-168=20Ref<>()=20=EA=B4=80=EC=9A=A9?= =?UTF-8?q?=EA=B5=AC,=20H-170=20resume=20=EC=A6=89=EC=8B=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20re-raise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ref.luau :Set — 순회 앞에서 리비전을 잡고 바뀌면 break(사용자 안, 권고 k(self.Value)는 콜백 이중 호출을 남겨 기각), coroutine.resume 결과 false면 error(err, 0). spec.ref 10·11. - ref-plan.md: :Set 블록·재진입 절·:Wait 정정·"제네릭 시그니처"에 H-168 읽기 규칙. lifecycle-hooks/debounce-throttle 관용구에 배너. - H-174: lifecycle-pattern.md·module-lifecycle-plan.md·ROADMAP 반응형 본체 — 반응형 모듈은 InitXxx(module) 팩토리, 게이트는 발화 시점에 module.canExecute(self)로(캡처 금지). - round11.md §4 전량 ✅ + 사용자 원문, 세션 원문·요약 갱신. 단위 2 게이트 0. Co-authored-by: qwreey --- .claude/base/debounce-throttle-plan.md | 2 +- .claude/base/lifecycle-hooks-plan.md | 3 + .claude/base/lifecycle-pattern.md | 9 ++- .claude/base/module-lifecycle-plan.md | 8 +++ .claude/base/ref-plan.md | 39 +++++++++++-- .../pre-implementation-handtrace-round11.md | 18 ++++-- .claude/session-summary.md | 5 +- .../2026-08-28-03-m2-unit1-common-base.md | 15 ++++- ROADMAP.md | 5 ++ quad-base/src/Ref.luau | 15 ++++- quad-base/test/spec.ref.luau | 57 ++++++++++++++++++- 11 files changed, 160 insertions(+), 16 deletions(-) diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index 106e218..89a2414 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -539,7 +539,7 @@ export type GateHandle = { Cancel: () -> (), -- 이 인스턴스만 pending을 버림(전파 없음) } -local h = Ref() +local h = Ref() -- [2026-08-28 `H-168`] 실제 코드는 `Ref<>()`(`base/ref-plan.md` "제네릭 시그니처") local debounced = state:Apply(Debounce{Time = 0.3, Handle = h}) -- 게이트가 실제로 만들어지는 시점(팩토리 호출 시)에 h가 채워짐: -- h.Value == { Flush = fn, Cancel = fn } -- 이 게이트 인스턴스 하나만 제어 diff --git a/.claude/base/lifecycle-hooks-plan.md b/.claude/base/lifecycle-hooks-plan.md index 3fa554f..e36c68b 100644 --- a/.claude/base/lifecycle-hooks-plan.md +++ b/.claude/base/lifecycle-hooks-plan.md @@ -72,6 +72,9 @@ end -- `guard`가 `fn(v, r)`로 부르므로 1-인자로 선언하면 `--!strict`에서 arity -- 에러다. 사용자가 1-인자 람다를 넘기는 건 그대로 된다(Luau 함수 타입은 -- 파라미터에 반변이라 인자를 덜 받는 함수가 대입 가능). +-- [2026-08-28 `H-168`] 무인자 `PreRef()`/`PostRef()`는 strict에서 타입 에러 — +-- 실제 코드는 `PreRef<>()`처럼 명시 확장으로 쓴다(`base/ref-plan.md` +-- "제네릭 시그니처"). 아래는 관용구 원문 그대로. local function OnCreated(fn: (inst: Instance, ref: PreRef) -> ()): PreRef return PreRef():Callback(guard(fn)) end diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 3f26c26..f45d067 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -188,7 +188,14 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 넷을 return하는 게 아니다(파일은 `InitLifetimeHandle(module)`을 return하고, 그 팩토리가 `module.bindLifetime = …` 에러 스텁 4종을 심는다 — 백엔드가 같은 필드를 덮어쓴다, 아래 "`Connected` 체크는 rbvm 패턴을 그대로 베끼는 게 아니라" 절). -아래 시그니처의 이름은 그 필드 이름이다: +아래 시그니처의 이름은 그 필드 이름이다. **⭐ [2026-08-28 `H-174`, 사용자 확정] +반응형 모듈(`Source`/`State`/`Observer`/`Effect`…)은 이 넷을 `module.canExecute(self)`처럼 +모듈 인스턴스에서 발화 시점에 늦게 읽는다** — 조립은 `InitXxx(module)` 팩토리가 +`module`을 클로저로 쥐는 형태(`base/module-lifecycle-plan.md` "New()의 내부 구성")이고, +`Init` 시점에 `local canExecute = module.canExecute`로 캡처하면 백엔드가 `New()` 뒤에 +덮어쓰기 전의 **스텁을 영원히 잡는다**. 이 문서와 `source-state-plan.md`/`effect-plan.md` +의사코드의 `canExecute(self)`/`canBound(self)`는 전부 그렇게 읽을 것(사용자: *"module.canExecute +로 lazy 하게 읽으면 되는거 아냐?"*). ```lua bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐 diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 481d820..be12803 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -32,6 +32,14 @@ RBVM처럼 `init namespace` 하나하나 부르는 방식은 별로(`base/lifecy ## New()의 내부 구성 — InitXxx 팩토리 체이닝 (2026-08-19 신설) +> **⭐ [2026-08-28 `H-174`, 사용자 확정] 반응형 모듈도 같은 팩토리형이다.** M2 첫 단위가 +> 생명주기 4종을 `InitLifetimeHandle(module)`로 **인스턴스별 필드**로 만들었으므로, +> `Source`/`State`/`Observer`/`Effect`처럼 그 게이트를 부르는 모듈은 `InitXxx(module)`이 +> `module`을 클로저로 쥐고 **발화 시점에 `module.canExecute(self)`로 늦게 읽는다**(백엔드가 +> `New()` 뒤에 덮어쓰므로 `Init` 시점 캡처는 스텁을 영원히 잡는다). `Ref`/`Void`/`Relate`/ +> `Brand`처럼 게이트를 안 부르는 잎 모듈만 인스턴스 간 공유된다. 소스는 +> `base/lifecycle-pattern.md` "탑레벨 평범한 함수로 확정" 절의 `H-174` 문단. + 바로 위 절이 확정한 `InitRoblox(Module)` 패턴(팩토리가 모듈 테이블을 뮤테이션)은 지금까지 서술상 **backend 주입**에만 적용되는 것처럼 보였는데, `New()` **자신**이 quad-base 내부 서브시스템(Dispatch 등)을 구성하는 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 2f95efe..14fda90 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -259,13 +259,19 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 -- `:Callback`과 `:WeakCallback`으로 각각 건 경우). if self.Callbacks[k] == nil then table.insert(snapshot, k) end end + local revision = self.Revision -- (4) [2026-08-28 `H-169`] 이 파동의 표식 for _, k in ipairs(snapshot) do + if self.Revision ~= revision then + break -- 콜백이 `:Set`을 재진입했다 — 안쪽 파동이 이미 새 값을 전부 + -- 돌렸으므로 이 파동의 나머지는 놓는다(아래 재진입 절) + end if self.Callbacks[k] == nil and self.WeakCallbacks[k] == nil then continue -- 순회 중 해제됐으면 skip end if type(k) == "thread" then self.Callbacks[k] = nil -- 대기자는 소진 - coroutine.resume(k, self) -- 값이 아니라 **Ref 자신**(아래 참고) + local ok, err = coroutine.resume(k, self) -- 값이 아니라 **Ref 자신**(아래 참고) + if not ok then error(err, 0) end -- [2026-08-28 `H-170`] 즉시 돌아온 실패는 올린다 else k(value, self) -- ⭐ 값 + **Ref 자신**(= `Epoch`) end @@ -273,6 +279,21 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 return self end ``` + - **⭐ [2026-08-28 `H-169`, 사용자 확정] 재진입 — 순회는 자기 리비전이 바뀌면 + 놓는다.** 콜백 A가 `ref:Set(2)`를 다시 부르면 안쪽 파동이 (스냅샷을 새로 떠서) + 등록된 콜백 전부에 2를 돌리고 돌아온다 — 바깥 파동이 그대로 이어지면 남은 콜백 + B가 `.Value == 2`인 채로 인자 `value == 1`을 받는다(`/code-review` 재현). 사용자: + *"반복문을 자신 epoch 를 보며 달라져버렸다면 놓으면 될듯. 후행 Set 이 전부 + 호출하도록. (진짜 항상 콜백이 받는건 최신임)"* — 그래서 (4)의 리비전 비교 한 줄. + `k(self.Value, self)`로 바꾸는 안(리뷰 권고)은 B가 같은 파동에서 **두 번**(안쪽에서 + 2, 바깥에서 또 2) 불리는 문제를 남겨 채택하지 않았다. 불변식 *"옛 값이 보이는 + 창이 없다"*는 이 모양으로 성립한다(`spec.ref.luau` 11). + - **[2026-08-28 `H-170`, 사용자 확정] `coroutine.resume`은 에러를 던지지 않는다** — + `(false, err)`를 돌려줄 뿐이라 대기자 안의 에러가 `:Set`에서 조용히 사라졌었다. + 즉시 돌아온 실패는 `error(err, 0)`으로 올린다(`architecture.md` "예외 안전성 계약 — + 감싸지 않는다"와 같은 결). 대기자가 다시 yield한 뒤 나는 에러는 우리 손 밖이다 + (사용자: *"후행 yield 로 나가는건 우리가 처리 어렵긴 해. 그치만 당장 돌아오는 결과 + false 은 확인 해줄 수 있는듯"*). - **왜 `k(value, self)`인가(`H-107`, 사용자 확정 2026-08-26)** — `Effect`가 `Ref`를 dep으로 걸면 발화 시 `EpochMap:Update(from)`에 넘길 `Epoch`가 필요한데, `k(value)`뿐이면 그 통로가 없어 `Update(nil)`이 된다 @@ -371,11 +392,19 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 정정돼 있었고 이 문장만 갱신에서 빠졌음). `sourceList`가 `None`인 것은 맞음. - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 - `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 - 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 - 둠. + `:Wait(thread)`에 넘기면 나중에 `:Set`이 그 thread를 resume할 때 실패한다 + (**[2026-08-28 `H-170` 정정]** `coroutine.resume` 자체는 에러를 던지지 않고 + `(false, "cannot resume dead coroutine")`를 돌려주므로, `:Set`이 그 실패를 + `error`로 올린다 — 위 `:Set` 블록) — 이건 다른 UB 케이스들과 같은 결로 + 라이브러리가 방어하지 않고 호출부 책임으로 둠. - **제네릭 시그니처(2026-08-07 확정): `Ref(T) -> Ref` — 단일 타입 - 파라미터.** React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 + 파라미터.** **⭐ [2026-08-28 `H-168`, 사용자 확정 — 시그니처 유지] 무인자 `Ref()`는 + `--!strict`에서 타입 에러다**(*"Function expects 1 argument"*, M2 첫 단위 실측). 이 + 코퍼스가 관용구로 쓰는 `Ref()`/`PreRef()`/`PostRef()`/`Handle = Ref()`는 전부 + **`Ref<>()`로 읽을 것** — 파라미터가 nil-able이면 Luau가 인자 생략을 허용하므로 + 명시 확장 하나로 빈 호출까지 성립한다(사용자: *"`A<<{}?>>()` 로 쓰면 없어도 호출 + 가능해짐. 시그니처 유지 권고를 따르고자 해"*). `default: T?`로 넓히는 안은 `Ref(5).Value` + 까지 nil 검사를 강요해 기각(`H-167`). 관용구 원문은 그대로 두고 이 규칙으로 읽는다. React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 분리"하는 2파라미터 설계도 검토했으나(예: `Ref<>(null)` → `HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와 인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게 diff --git a/.claude/qa-request/pre-implementation-handtrace-round11.md b/.claude/qa-request/pre-implementation-handtrace-round11.md index 42582bc..5034673 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round11.md +++ b/.claude/qa-request/pre-implementation-handtrace-round11.md @@ -17,13 +17,13 @@ | `H-165` | ① | 1 | 🟡 | `quad-types`에 `export type`을 더하면 pesde shim이 그걸 모른다 — `pesde install` 재실행 없이는 `QuadTypes.Ref`가 Unknown type | ✅ 반영(`project-setup-plan.md`) | | `H-166` | ① | 1 | 🟢 | `Ref.Revision` 초기값을 어느 문서도 안 정했다 | ✅ 반영(`ref-plan.md`: `0`) | | `H-167` | ① | 1 | 🟡 | 옮기며 `Ref(default: T?)`/`.Value: T?`로 바꿔 놓았다 — 문서는 `Ref(T)` 단일 파라미터, nil은 `Ref<>(nil)`로 | ✅ 코드를 문서에 맞춤(감사 2라운드) | -| `H-168` | **②** | 1 | 🟡 | `Ref(T)`면 무인자 `Ref()`가 strict에서 TypeError("expects 1 argument") — 그런데 `ref-plan.md`/`lifecycle-hooks-plan.md`/`debounce-throttle-plan.md`가 `Ref()`/`PreRef()`를 관용구로 가르친다 | §4 대기 | -| `H-169` | **②** | 1 | 🟡 | `:Set` 블록이 스냅샷 콜백에 닫힌 인자 `value`를 넘겨서, 콜백 안 재진입 `ref:Set(new)` 뒤 남은 콜백이 **옛 값**을 받는다 — 문서 블록 그대로인데 문서의 "옛 값이 보이는 창이 없다"와 어긋남 | §4 대기 | -| `H-170` | **②** | 1 | 🟡 | `coroutine.resume(k, self)`는 에러를 올리지 않는다 — 대기자 안의 에러·죽은 thread resume이 `:Set`에서 조용히 삼켜짐. `ref-plan.md` *"나중에 `coroutine.resume`이 에러남"*은 사실이 아님 | §4 대기 | +| `H-168` | **②** | 1 | 🟡 | `Ref(T)`면 무인자 `Ref()`가 strict에서 TypeError("expects 1 argument") — 그런데 `ref-plan.md`/`lifecycle-hooks-plan.md`/`debounce-throttle-plan.md`가 `Ref()`/`PreRef()`를 관용구로 가르친다 | ✅ (a) 사용자 확정 — 시그니처 유지, 관용구는 `Ref<>()`로 읽음(`ref-plan.md` "제네릭 시그니처") | +| `H-169` | **②** | 1 | 🟡 | `:Set` 블록이 스냅샷 콜백에 닫힌 인자 `value`를 넘겨서, 콜백 안 재진입 `ref:Set(new)` 뒤 남은 콜백이 **옛 값**을 받는다 — 문서 블록 그대로인데 문서의 "옛 값이 보이는 창이 없다"와 어긋남 | ✅ 사용자 확정(권고 (a) 아님) — 순회가 자기 리비전이 바뀌면 놓고 후행 `Set`이 전부 호출(`spec.ref` 11) | +| `H-170` | **②** | 1 | 🟡 | `coroutine.resume(k, self)`는 에러를 올리지 않는다 — 대기자 안의 에러·죽은 thread resume이 `:Set`에서 조용히 삼켜짐. `ref-plan.md` *"나중에 `coroutine.resume`이 에러남"*은 사실이 아님 | ✅ (a) 사용자 확정 — 즉시 반환된 실패만 `error(err, 0)`(`spec.ref` 10) | | `H-171` | ① | 1 | 🟡 | mock lazy claim: Destroy된 inst에 다시 bind하면 GC 전엔 죽은 gcconn 재사용, GC 뒤엔 새 Connected gcconn — 결과가 GC 타이밍에 따라 갈림 | ✅ mock: 죽은 inst의 새 gcconn은 즉시 Disconnect(`spec.lifetime` 6b) | | `H-172` | ① | 1 | 🟡 | mock `Destroy`가 자손을 안 죽이고(조상 파괴 계약 검증 불가), `Parent` 변경 시그널이 연결 해제 뒤라 관측 불가, 재귀 Destroy 무한 루프 | ✅ mock: Destroying → Parent nil → 자손 Destroy → 연결 해제, 이중 Destroy no-op(`spec.lifetime` 6c) | | `H-173` | ① | 1 | 🟢 | `ROADMAP.md` M7 체크박스·`tween-plan.md` 352가 `isTween`/`TweenBrand`를 `Tween.luau`에 둔다고 아직 서술(감사 3라운드가 두 곳만 고침) | ✅ 반영 | -| `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | §4 대기 | +| `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | ✅ (a) 사용자 확정 — 팩토리형, `module.canExecute(self)`를 발화 시점에 늦게 읽음(`lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체) | | `H-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 | ## 상세 @@ -163,6 +163,16 @@ | **`H-170`** | resume이 삼키는 에러 | (a) `local ok, err = coroutine.resume(k, self); if not ok then error(err, 0) end` — 대기자 에러를 `:Set` 호출부로 다시 올림 / (b) UB로 문서화(대기자 에러는 사라진다) / (c) 대기자 소진을 `task.spawn`류 주입 op로(Roblox `task.spawn`은 에러를 콘솔로 보냄) | **(a)** | `architecture.md` "예외 안전성 계약"(감싸지 않는다 = 에러는 전파)과 같은 결. 다만 **새 코드 두 줄(re-raise)** 이라 사용자 결정 자리. (c)는 새 주입 op | 아니오 | | **`H-174`** (탐사자, **단위 2 착수 전 필요**) | 반응형 모듈이 자기 `quad` 인스턴스의 `canExecute`/`canBound`에 닿는 법 | (a) `Source.luau`/`State.luau`/`Observer.luau`/`Effect.luau`를 `InitSource(module)`류 **팩토리**로 — 클래스와 `Subscribed`/`WeakSubscribed` 레지스트리를 `module`을 닫은 클로저 안에서 만들고 `module.Source = …`로 심음(`InitDispatch`와 같은 모양, `EpochMap`/`Brand`/`Ref`는 그대로 잎). 공개 필드가 없는 `State`/`Observer` 클래스는 `Init`의 반환값이나 `module.RunInit` 뒤의 비공개 필드로 형제 `Init`에 넘김 / (b) 잎 모듈 유지 + 값마다 역참조 필드(`observer._quad`)를 두고 `self._quad.canExecute(self)` / (c) 잎 모듈 유지 + 모듈 로컬 슬롯 하나를 백엔드가 채움(인스턴스 격리 포기) | **(a)** | `architecture.md` 13번(*"모듈 인스턴스를 인자로 받도록"*)과 `module-lifecycle-plan.md` "New()의 내부 구성"이 이미 이 모양이고, `Observer`의 두 레지스트리가 인스턴스별이 되어 *"완전히 별도의 새 Quad 네임스페이스"*와 맞음. (b)는 새 필드 + 값이 자기 모듈을 강참조(`Relate` 되참조 계열 위험), (c)는 `spec.lifetime` 8·`New()`의 존재 이유와 충돌. 어느 쪽이든 **필드는 발화 시점에 늦게 읽는다**(상세 절의 하위 함정) | 아니오 | +**[2026-08-28 회신 — 단위 1 배치 전량 확정]** 사용자 원문: *"174 는 module.canExecute 로 +lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복문을 자신 epoch 를 보며 +달라져버렸다면 놓으면 될듯. 후행 Set 이 전부 호출하도록. (진짜 항상 콜백이 받는건 +최신임) / ref 쪽에서는 `local function A(a: T)end A<<{}?>>()` 로 쓰면 없어도 호출 +가능해짐. 시그니처 유지 권고를 따르고자 해. / coroutine.resume 은 애초에 에러가 날지 안 +날지 후행 yield 로 나가는건 우리가 처리 어렵긴 해. 그치만 당장 돌아오는 결과 false 은 +확인 해줄 수 있는듯. a 권고사항 따르기로"*. `H-169`만 권고 (a)(`k(self.Value, self)`)가 +아니라 **사용자 안**(리비전 비교로 파동을 놓음)으로 — 권고안은 같은 파동에서 콜백이 두 +번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열. + 코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야 한다. **[2026-08-28 기준] 마커 0개** — 위 셋은 단위 1 모듈을 막지 않아 코드는 문서 블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제). diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 5f6ac5b..81d7b6b 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -2026,4 +2026,7 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs `pesde install` 재실행)·`H-166`(`Revision` 초기값 0) 둘 다 ①. 단위 끝: 감사 8라운드 수렴(`H-167` `Ref(T)` 되돌림 포함), `/code-review high` 10건 → ② 셋(`H-168` `Ref()` 무인자 관용구 / `H-169` 재진입 `:Set` 옛 값 / `H-170` resume이 에러 삼킴, 권고 전부 (a)) - §4로, ① 셋(mock lazy claim·`Destroy` 의미론·M7 `TweenBrand` 잔재) 반영. + §4로, ① 셋(mock lazy claim·`Destroy` 의미론·M7 `TweenBrand` 잔재) 반영. 탐사자 + 🔴 `H-174`(생명주기 필드 조립 형태). **같은 날 배치 회신으로 넷 다 확정**: `H-174` + 팩토리형 + `module.canExecute` 늦게 읽기 / `H-169` 사용자 안(리비전 바뀌면 파동을 + 놓음, 권고안 기각) / `H-168` `Ref<>()` / `H-170` 즉시 실패만 re-raise. 단위 2 게이트 0. diff --git a/.claude/session/2026-08-28-03-m2-unit1-common-base.md b/.claude/session/2026-08-28-03-m2-unit1-common-base.md index a8e52d1..dc647ef 100644 --- a/.claude/session/2026-08-28-03-m2-unit1-common-base.md +++ b/.claude/session/2026-08-28-03-m2-unit1-common-base.md @@ -49,6 +49,19 @@ claim GC 타이밍) / `H-172`(mock `Destroy` 의미론) / `H-173`(M7 `TweenBrand` 잔재) 반영, 잔손질 셋 반영, 기각 셋. +- fable 탐사자: 🔴 `H-174`(생명주기 4종이 인스턴스 필드인데 반응형 의사코드는 자유 + 함수 — 조립 형태 미정) / 🟢 `H-175`(클로저 캐시 규칙 범위) → "§4를 보라". + +## 배치 회신 (같은 날) + +사용자가 §4 넷을 한 번에 답함(원문은 `round11.md` §4). `H-174` 팩토리형 + +`module.canExecute` 늦게 읽기 / `H-169` **사용자 안** — 순회가 자기 리비전이 바뀌면 놓고 +후행 `Set`이 전부 호출(권고 `k(self.Value, self)`는 콜백 이중 호출을 남겨 기각) / +`H-168` 시그니처 유지, 관용구는 `Ref<>()` / `H-170` 즉시 반환 `false`만 올림. +반영: `Ref.luau`(리비전 가드·resume 확인) + `spec.ref` 10·11, `ref-plan.md` `:Set` 블록· +재진입 절·`:Wait` 정정·"제네릭 시그니처" 규칙, `lifecycle-hooks`/`debounce-throttle` +배너, `lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체에 `H-174`. + ## 다음 -fable 탐사자(규약 §5) → 사용자에게 "`round11.md` §4를 보라". §4엔 문항 셋, 권고 전부 (a). +단위 2(`EpochMap` → `Source`/`State`/`Store`) 착수 — 게이트 없음. diff --git a/ROADMAP.md b/ROADMAP.md index 2c9617e..3ac0cd3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -370,6 +370,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ### 반응형 본체 +> **[2026-08-28 `H-174`, 사용자 확정]** 이 절의 모듈은 `InitXxx(module)` 팩토리로 조립하고 +> 생명주기 게이트는 `module.canExecute(self)`로 **발화 시점에** 읽는다(`Init` 시점 캡처 +> 금지 — 백엔드가 `New()` 뒤에 덮어쓴다). 의사코드의 `canExecute(self)`는 전부 그 뜻. +> 소스는 `base/lifecycle-pattern.md`의 `H-174` 문단. + - [ ] **`EpochMap.luau`** (**[2026-08-24]** 2026-08-22에 디스패치로 옮겼다가 순서 교체로 되돌아옴) — 재사용 가능한 에포크 부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = diff --git a/quad-base/src/Ref.luau b/quad-base/src/Ref.luau index 92bacb8..91651b5 100644 --- a/quad-base/src/Ref.luau +++ b/quad-base/src/Ref.luau @@ -60,13 +60,26 @@ function RefImpl.Set(self: Ref, value: T): Ref table.insert(snapshot, k) end end + local revision = self.Revision for _, k in ipairs(snapshot) do + if self.Revision ~= revision then + -- [`H-169`, user-confirmed 2026-08-28] a callback re-entered `:Set`: that + -- inner walk already delivered the newer value to everyone, so this + -- walk drops the rest — a callback never receives a stale value. + break + end if callbacks[k] == nil and weakCallbacks[k] == nil then continue -- released while we were walking end if type(k) == "thread" then callbacks[k] = nil -- waiters are consumed - coroutine.resume(k, self) -- the Ref itself, not the value + -- the Ref itself, not the value. [`H-170`, user-confirmed] `resume` never + -- raises; the immediately returned failure is re-raised here (a later + -- yield's fate is out of our hands), per the "no pcall" contract. + local ok, err = coroutine.resume(k, self) + if not ok then + error(err, 0) + end else -- value + the Ref itself (= `Epoch`). The cast only quiets the checker: -- `k` comes from a `{ any }` snapshot and the `thread` branch is above. diff --git a/quad-base/test/spec.ref.luau b/quad-base/test/spec.ref.luau index 479e1da..f109da2 100644 --- a/quad-base/test/spec.ref.luau +++ b/quad-base/test/spec.ref.luau @@ -199,8 +199,61 @@ do assert(got == r, "waiter is resumed with the Ref itself") assert(r.Callbacks[co] == nil, "waiter is consumed") assert(coroutine.status(co) == "dead", "waiter ran to completion") - r:Set(2) -- 소진 확인은 위 Callbacks[co] == nil로 충분 — coroutine.resume은 에러를 올리지 않아 - -- 죽은 코루틴을 다시 resume해도 여기서 티가 안 난다(리뷰 `H-170`, §4 문항) + r:Set(2) -- 소진됐으니 다시 resume 안 함(위 Callbacks[co] == nil이 그 증거) + print("PASS") +end + +print() +print("=== 10. [H-170] 대기자 안의 에러는 :Set 호출부로 올라온다 (즉시 반환된 false 확인) ===") +do + local r = Ref(0) + local co = coroutine.create(function() + coroutine.yield() + error("waiter exploded") + end) + coroutine.resume(co) + r.Callbacks[co] = true + local ok, err = pcall(function() + r:Set(1) + end) + assert(not ok and string.find(tostring(err), "waiter exploded", 1, true) ~= nil, "resume failure must surface: " .. tostring(err)) + assert(r.Callbacks[co] == nil and r.Value == 1, "the waiter was still consumed and the value settled before the error") + -- 이미 죽은 thread를 대기자로 넣는 것도 같은 경로로 드러난다 + local dead = coroutine.create(function() end) + coroutine.resume(dead) + r.Callbacks[dead] = true + local ok2 = pcall(function() + r:Set(2) + end) + assert(not ok2, "resuming a dead waiter surfaces as an error") + print("PASS") +end + +print() +print("=== 11. [H-169] 재진입 :Set — 순회는 자기 리비전이 바뀌면 놓고, 후행 Set이 전부 호출 ===") +do + local r = Ref(0) + local seen: { { value: number, current: number } } = {} + local walking = false + local function a(v: number, ref: any) + if walking and v == 1 then + ref:Set(2) -- 안쪽 파동: 등록된 콜백 전부가 2를 받는다 + end + end + local function b(v: number, ref: any) + if walking then + table.insert(seen, { value = v, current = ref.Value :: number }) + end + end + r:Callback(a):Callback(b) + walking = true + r:Set(1) + walking = false + -- b는 안쪽 파동에서 (2, 2)를 받았고, 바깥 파동의 남은 순회는 놓아졌으므로 (1, 2)는 없다 + for _, s in seen do + assert(s.value == s.current, "a callback must never receive a value older than ref.Value, got " .. s.value .. " vs " .. s.current) + end + assert(#seen >= 1 and seen[#seen].value == 2, "the newest value reached b") print("PASS") end