qa: 단위 1 배치 회신 반영 — H-174 팩토리형+module.canExecute 늦게 읽기, H-169 리비전 가드로 재진입 파동 놓기, H-168 Ref<<T?>>() 관용구, H-170 resume 즉시 실패 re-raise
- 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 <me@qwreey.moe>
This commit is contained in:
parent
31c55c625e
commit
4169d7ba50
11 changed files with 160 additions and 16 deletions
|
|
@ -539,7 +539,7 @@ export type GateHandle = {
|
||||||
Cancel: () -> (), -- 이 인스턴스만 pending을 버림(전파 없음)
|
Cancel: () -> (), -- 이 인스턴스만 pending을 버림(전파 없음)
|
||||||
}
|
}
|
||||||
|
|
||||||
local h = Ref()
|
local h = Ref() -- [2026-08-28 `H-168`] 실제 코드는 `Ref<<DebounceHandle?>>()`(`base/ref-plan.md` "제네릭 시그니처")
|
||||||
local debounced = state:Apply(Debounce{Time = 0.3, Handle = h})
|
local debounced = state:Apply(Debounce{Time = 0.3, Handle = h})
|
||||||
-- 게이트가 실제로 만들어지는 시점(팩토리 호출 시)에 h가 채워짐:
|
-- 게이트가 실제로 만들어지는 시점(팩토리 호출 시)에 h가 채워짐:
|
||||||
-- h.Value == { Flush = fn, Cancel = fn } -- 이 게이트 인스턴스 하나만 제어
|
-- h.Value == { Flush = fn, Cancel = fn } -- 이 게이트 인스턴스 하나만 제어
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ end
|
||||||
-- `guard`가 `fn(v, r)`로 부르므로 1-인자로 선언하면 `--!strict`에서 arity
|
-- `guard`가 `fn(v, r)`로 부르므로 1-인자로 선언하면 `--!strict`에서 arity
|
||||||
-- 에러다. 사용자가 1-인자 람다를 넘기는 건 그대로 된다(Luau 함수 타입은
|
-- 에러다. 사용자가 1-인자 람다를 넘기는 건 그대로 된다(Luau 함수 타입은
|
||||||
-- 파라미터에 반변이라 인자를 덜 받는 함수가 대입 가능).
|
-- 파라미터에 반변이라 인자를 덜 받는 함수가 대입 가능).
|
||||||
|
-- [2026-08-28 `H-168`] 무인자 `PreRef()`/`PostRef()`는 strict에서 타입 에러 —
|
||||||
|
-- 실제 코드는 `PreRef<<Instance?>>()`처럼 명시 확장으로 쓴다(`base/ref-plan.md`
|
||||||
|
-- "제네릭 시그니처"). 아래는 관용구 원문 그대로.
|
||||||
local function OnCreated(fn: (inst: Instance, ref: PreRef<Instance>) -> ()): PreRef<Instance>
|
local function OnCreated(fn: (inst: Instance, ref: PreRef<Instance>) -> ()): PreRef<Instance>
|
||||||
return PreRef():Callback(guard(fn))
|
return PreRef():Callback(guard(fn))
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,14 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
|
||||||
넷을 return하는 게 아니다(파일은 `InitLifetimeHandle(module)`을 return하고, 그
|
넷을 return하는 게 아니다(파일은 `InitLifetimeHandle(module)`을 return하고, 그
|
||||||
팩토리가 `module.bindLifetime = …` 에러 스텁 4종을 심는다 — 백엔드가 같은 필드를
|
팩토리가 `module.bindLifetime = …` 에러 스텁 4종을 심는다 — 백엔드가 같은 필드를
|
||||||
덮어쓴다, 아래 "`Connected` 체크는 rbvm 패턴을 그대로 베끼는 게 아니라" 절).
|
덮어쓴다, 아래 "`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
|
```lua
|
||||||
bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐
|
bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,14 @@ RBVM처럼 `init namespace` 하나하나 부르는 방식은 별로(`base/lifecy
|
||||||
|
|
||||||
## New()의 내부 구성 — InitXxx 팩토리 체이닝 (2026-08-19 신설)
|
## 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)` 패턴(팩토리가 모듈 테이블을
|
바로 위 절이 확정한 `InitRoblox(Module)` 패턴(팩토리가 모듈 테이블을
|
||||||
뮤테이션)은 지금까지 서술상 **backend 주입**에만 적용되는 것처럼 보였는데,
|
뮤테이션)은 지금까지 서술상 **backend 주입**에만 적용되는 것처럼 보였는데,
|
||||||
`New()` **자신**이 quad-base 내부 서브시스템(Dispatch 등)을 구성하는
|
`New()` **자신**이 quad-base 내부 서브시스템(Dispatch 등)을 구성하는
|
||||||
|
|
|
||||||
|
|
@ -259,13 +259,19 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
||||||
-- `:Callback`과 `:WeakCallback`으로 각각 건 경우).
|
-- `:Callback`과 `:WeakCallback`으로 각각 건 경우).
|
||||||
if self.Callbacks[k] == nil then table.insert(snapshot, k) end
|
if self.Callbacks[k] == nil then table.insert(snapshot, k) end
|
||||||
end
|
end
|
||||||
|
local revision = self.Revision -- (4) [2026-08-28 `H-169`] 이 파동의 표식
|
||||||
for _, k in ipairs(snapshot) do
|
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
|
if self.Callbacks[k] == nil and self.WeakCallbacks[k] == nil then
|
||||||
continue -- 순회 중 해제됐으면 skip
|
continue -- 순회 중 해제됐으면 skip
|
||||||
end
|
end
|
||||||
if type(k) == "thread" then
|
if type(k) == "thread" then
|
||||||
self.Callbacks[k] = nil -- 대기자는 소진
|
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
|
else
|
||||||
k(value, self) -- ⭐ 값 + **Ref 자신**(= `Epoch`)
|
k(value, self) -- ⭐ 값 + **Ref 자신**(= `Epoch`)
|
||||||
end
|
end
|
||||||
|
|
@ -273,6 +279,21 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
||||||
return self
|
return self
|
||||||
end
|
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`가
|
- **왜 `k(value, self)`인가(`H-107`, 사용자 확정 2026-08-26)** — `Effect`가
|
||||||
`Ref`를 dep으로 걸면 발화 시 `EpochMap:Update(from)`에 넘길 `Epoch`가
|
`Ref`를 dep으로 걸면 발화 시 `EpochMap:Update(from)`에 넘길 `Epoch`가
|
||||||
필요한데, `k(value)`뿐이면 그 통로가 없어 `Update(nil)`이 된다
|
필요한데, `k(value)`뿐이면 그 통로가 없어 `Update(nil)`이 된다
|
||||||
|
|
@ -371,11 +392,19 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
||||||
정정돼 있었고 이 문장만 갱신에서 빠졌음). `sourceList`가 `None`인 것은
|
정정돼 있었고 이 문장만 갱신에서 빠졌음). `sourceList`가 `None`인 것은
|
||||||
맞음.
|
맞음.
|
||||||
- **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를
|
- **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를
|
||||||
`:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건
|
`:Wait(thread)`에 넘기면 나중에 `:Set`이 그 thread를 resume할 때 실패한다
|
||||||
다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로
|
(**[2026-08-28 `H-170` 정정]** `coroutine.resume` 자체는 에러를 던지지 않고
|
||||||
둠.
|
`(false, "cannot resume dead coroutine")`를 돌려주므로, `:Set`이 그 실패를
|
||||||
|
`error`로 올린다 — 위 `:Set` 블록) — 이건 다른 UB 케이스들과 같은 결로
|
||||||
|
라이브러리가 방어하지 않고 호출부 책임으로 둠.
|
||||||
- **제네릭 시그니처(2026-08-07 확정): `Ref<T>(T) -> Ref<T>` — 단일 타입
|
- **제네릭 시그니처(2026-08-07 확정): `Ref<T>(T) -> Ref<T>` — 단일 타입
|
||||||
파라미터.** React `useRef<T, U=T>(U): T|U`류 "초기값 타입과 최종 타입을
|
파라미터.** **⭐ [2026-08-28 `H-168`, 사용자 확정 — 시그니처 유지] 무인자 `Ref()`는
|
||||||
|
`--!strict`에서 타입 에러다**(*"Function expects 1 argument"*, M2 첫 단위 실측). 이
|
||||||
|
코퍼스가 관용구로 쓰는 `Ref()`/`PreRef()`/`PostRef()`/`Handle = Ref()`는 전부
|
||||||
|
**`Ref<<T?>>()`로 읽을 것** — 파라미터가 nil-able이면 Luau가 인자 생략을 허용하므로
|
||||||
|
명시 확장 하나로 빈 호출까지 성립한다(사용자: *"`A<<{}?>>()` 로 쓰면 없어도 호출
|
||||||
|
가능해짐. 시그니처 유지 권고를 따르고자 해"*). `default: T?`로 넓히는 안은 `Ref(5).Value`
|
||||||
|
까지 nil 검사를 강요해 기각(`H-167`). 관용구 원문은 그대로 두고 이 규칙으로 읽는다. React `useRef<T, U=T>(U): T|U`류 "초기값 타입과 최종 타입을
|
||||||
분리"하는 2파라미터 설계도 검토했으나(예: `Ref<<HTMLDivElement>>(null)`
|
분리"하는 2파라미터 설계도 검토했으나(예: `Ref<<HTMLDivElement>>(null)`
|
||||||
→ `HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와
|
→ `HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와
|
||||||
인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게
|
인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,13 @@
|
||||||
| `H-165` | ① | 1 | 🟡 | `quad-types`에 `export type`을 더하면 pesde shim이 그걸 모른다 — `pesde install` 재실행 없이는 `QuadTypes.Ref`가 Unknown type | ✅ 반영(`project-setup-plan.md`) |
|
| `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-166` | ① | 1 | 🟢 | `Ref.Revision` 초기값을 어느 문서도 안 정했다 | ✅ 반영(`ref-plan.md`: `0`) |
|
||||||
| `H-167` | ① | 1 | 🟡 | 옮기며 `Ref<T>(default: T?)`/`.Value: T?`로 바꿔 놓았다 — 문서는 `Ref<T>(T)` 단일 파라미터, nil은 `Ref<<T?>>(nil)`로 | ✅ 코드를 문서에 맞춤(감사 2라운드) |
|
| `H-167` | ① | 1 | 🟡 | 옮기며 `Ref<T>(default: T?)`/`.Value: T?`로 바꿔 놓았다 — 문서는 `Ref<T>(T)` 단일 파라미터, nil은 `Ref<<T?>>(nil)`로 | ✅ 코드를 문서에 맞춤(감사 2라운드) |
|
||||||
| `H-168` | **②** | 1 | 🟡 | `Ref<T>(T)`면 무인자 `Ref()`가 strict에서 TypeError("expects 1 argument") — 그런데 `ref-plan.md`/`lifecycle-hooks-plan.md`/`debounce-throttle-plan.md`가 `Ref()`/`PreRef()`를 관용구로 가르친다 | §4 대기 |
|
| `H-168` | **②** | 1 | 🟡 | `Ref<T>(T)`면 무인자 `Ref()`가 strict에서 TypeError("expects 1 argument") — 그런데 `ref-plan.md`/`lifecycle-hooks-plan.md`/`debounce-throttle-plan.md`가 `Ref()`/`PreRef()`를 관용구로 가르친다 | ✅ (a) 사용자 확정 — 시그니처 유지, 관용구는 `Ref<<T?>>()`로 읽음(`ref-plan.md` "제네릭 시그니처") |
|
||||||
| `H-169` | **②** | 1 | 🟡 | `:Set` 블록이 스냅샷 콜백에 닫힌 인자 `value`를 넘겨서, 콜백 안 재진입 `ref:Set(new)` 뒤 남은 콜백이 **옛 값**을 받는다 — 문서 블록 그대로인데 문서의 "옛 값이 보이는 창이 없다"와 어긋남 | §4 대기 |
|
| `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`이 에러남"*은 사실이 아님 | §4 대기 |
|
| `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-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-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-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` 주석 좁힘 |
|
| `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-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()`의 존재 이유와 충돌. 어느 쪽이든 **필드는 발화 시점에 늦게 읽는다**(상세 절의 하위 함정) | 아니오 |
|
| **`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<T>(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이어야
|
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
|
||||||
한다. **[2026-08-28 기준] 마커 0개** — 위 셋은 단위 1 모듈을 막지 않아 코드는 문서
|
한다. **[2026-08-28 기준] 마커 0개** — 위 셋은 단위 1 모듈을 막지 않아 코드는 문서
|
||||||
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
|
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
|
||||||
|
|
|
||||||
|
|
@ -2026,4 +2026,7 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
|
||||||
`pesde install` 재실행)·`H-166`(`Revision` 초기값 0) 둘 다 ①. 단위 끝: 감사 8라운드
|
`pesde install` 재실행)·`H-166`(`Revision` 초기값 0) 둘 다 ①. 단위 끝: 감사 8라운드
|
||||||
수렴(`H-167` `Ref<T>(T)` 되돌림 포함), `/code-review high` 10건 → ② 셋(`H-168` `Ref()`
|
수렴(`H-167` `Ref<T>(T)` 되돌림 포함), `/code-review high` 10건 → ② 셋(`H-168` `Ref()`
|
||||||
무인자 관용구 / `H-169` 재진입 `:Set` 옛 값 / `H-170` resume이 에러 삼킴, 권고 전부 (a))
|
무인자 관용구 / `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<<T?>>()` / `H-170` 즉시 실패만 re-raise. 단위 2 게이트 0.
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,19 @@
|
||||||
claim GC 타이밍) / `H-172`(mock `Destroy` 의미론) / `H-173`(M7 `TweenBrand` 잔재) 반영,
|
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<<T?>>()` / `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`) 착수 — 게이트 없음.
|
||||||
|
|
|
||||||
|
|
@ -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에 디스패치로 옮겼다가 순서 교체로 되돌아옴) — 재사용 가능한 에포크
|
- [ ] **`EpochMap.luau`** (**[2026-08-24]** 2026-08-22에 디스패치로 옮겼다가 순서 교체로 되돌아옴) — 재사용 가능한 에포크
|
||||||
부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가
|
부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가
|
||||||
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet =
|
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet =
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,26 @@ function RefImpl.Set<T>(self: Ref<T>, value: T): Ref<T>
|
||||||
table.insert(snapshot, k)
|
table.insert(snapshot, k)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
local revision = self.Revision
|
||||||
for _, k in ipairs(snapshot) do
|
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
|
if callbacks[k] == nil and weakCallbacks[k] == nil then
|
||||||
continue -- released while we were walking
|
continue -- released while we were walking
|
||||||
end
|
end
|
||||||
if type(k) == "thread" then
|
if type(k) == "thread" then
|
||||||
callbacks[k] = nil -- waiters are consumed
|
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
|
else
|
||||||
-- value + the Ref itself (= `Epoch`). The cast only quiets the checker:
|
-- value + the Ref itself (= `Epoch`). The cast only quiets the checker:
|
||||||
-- `k` comes from a `{ any }` snapshot and the `thread` branch is above.
|
-- `k` comes from a `{ any }` snapshot and the `thread` branch is above.
|
||||||
|
|
|
||||||
|
|
@ -199,8 +199,61 @@ do
|
||||||
assert(got == r, "waiter is resumed with the Ref itself")
|
assert(got == r, "waiter is resumed with the Ref itself")
|
||||||
assert(r.Callbacks[co] == nil, "waiter is consumed")
|
assert(r.Callbacks[co] == nil, "waiter is consumed")
|
||||||
assert(coroutine.status(co) == "dead", "waiter ran to completion")
|
assert(coroutine.status(co) == "dead", "waiter ran to completion")
|
||||||
r:Set(2) -- 소진 확인은 위 Callbacks[co] == nil로 충분 — coroutine.resume은 에러를 올리지 않아
|
r:Set(2) -- 소진됐으니 다시 resume 안 함(위 Callbacks[co] == nil이 그 증거)
|
||||||
-- 죽은 코루틴을 다시 resume해도 여기서 티가 안 난다(리뷰 `H-170`, §4 문항)
|
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")
|
print("PASS")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue