PreRef pre-pass 소진 슬롯이 None으로 뭉뚱그려져 있어 setLength/ setOffsetSource 등록 책임자가 불분명했던 갭(같은 세션 조사에서 발견)을, 전용 센티널 ProcessedPreRef + ProcessedPreRefHandler로 교체해 "이 위치를 처음 매치한 Handler가 등록 책임을 진다"는 기존 계약에 특수 취급 없이 편입시킴. 파생 서술(동적 경로 가드, 취소 개념 없음 근거) 정정 포함. PostRef 백로그 스케치도 같은 원리로 갱신 — 별도 후행 재순회 없이 PreRef pre-pass 한 번의 스윕에서 isPostRef도 같이 소진하고 postRefList에 적재해두는 안으로 PreRef/PostRef 소진 메커니즘을 완전히 대칭화. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
7.6 KiB
Text
178 lines
7.6 KiB
Text
--[[
|
|
검증 대상: 배열 슬롯을 "소진"시킬 때 nil로 지울지 None 센티널로 지울지는
|
|
그 배열의 성격(순서가 중요한가, 슬롯 재사용이 필요한가)에 따라 갈린다는,
|
|
2026-08-09 열한 번째 세션에 재정정된 최종 결론.
|
|
|
|
**[중요, 2026-08-09 커밋 f198fd9 반영]** 이 파일의 이전 버전은
|
|
"Ref 콜백/대기자 배열도 None으로 소진해야 한다"고 잘못 적어뒀었음 —
|
|
사용자가 직접 찾아낸 버그: None으로 소진하면 그 슬롯이 영원히
|
|
non-nil로 남아있어서, `:Wait()`/`:Callback()`가 반복 호출될 때마다
|
|
배열이 끝없이 길어지는(예전 소진 슬롯을 재사용 못 하는) 진짜 버그였음.
|
|
.claude/base/ref-plan.md "왜 `None`이 아니라 `nil`인가" 절(2026-08-09
|
|
열한 번째 세션, 최종 정정)이 최신 소스 — 결론은 두 패턴이 서로 다른
|
|
문제를 풀고 있었다는 것:
|
|
|
|
- **순서가 중요한 배열(PreRef pre-pass 소진 슬롯, Length/Offset의
|
|
sourceList)**: 계속 실재하는 센티널(구멍이 아닌 non-nil 값) — 구멍이
|
|
생기면 해시 파트로 밀려 index 순회 순서가 깨지므로, "채워짐"을
|
|
유지해야 함. 이 스크립트는 그 센티널을 `None`으로 대표해 검증하지만,
|
|
**[2026-08-14 두 번째 세션] PreRef pre-pass 소진 슬롯의 실제 값은
|
|
이후 `None`에서 전용 센티널 `ProcessedPreRef`로 갈렸음**(`base/
|
|
ref-plan.md` "PreRef" 절) — 여기서 검증하는 성질(실재하는 non-nil
|
|
값이면 순서/`#t`가 안 깨진다) 자체는 어느 센티널을 쓰든 동일해서
|
|
이 결과는 계속 유효, 재실행 불필요.
|
|
- **순서가 안 중요하고 슬롯 재사용이 필요한 배열(Ref 콜백/대기자
|
|
리스트)**: `nil` + "빈 슬롯을 선형 탐색해 재사용"하는 등록 함수
|
|
(`table.insert`는 안 씀 — 구멍 있는 테이블에서 `#t`가 미정의
|
|
동작이라서). 순서 자체는 문제 안 됨 — 일반화 `for`는 구멍이 있어도
|
|
모든 non-nil 엔트리를 빠짐없이 방문하기 때문.
|
|
|
|
이 스크립트는 두 패턴을 나란히 재현해서 각각이 실제로 옳은 선택인지,
|
|
그리고 "None을 잘못 썼을 때 실제로 배열이 끝없이 자라는" 버그 자체도
|
|
수치로 보여줌.
|
|
|
|
실행: `luau 02-none-sentinel-vs-nil-holes.luau`
|
|
]]
|
|
|
|
local None = setmetatable({}, { __tostring = function()
|
|
return "<None>"
|
|
end })
|
|
|
|
-- ===============================================================
|
|
-- Part A. 순서가 중요한 배열 — None이 맞는 경우 (PreRef pre-pass, sourceList)
|
|
-- ===============================================================
|
|
|
|
print("=== A. 순서가 중요한 배열: None으로 소진해야 순서/#t가 안 깨짐 ===")
|
|
|
|
local N = 50
|
|
|
|
local function buildList(n)
|
|
local t = {}
|
|
for i = 1, n do
|
|
t[i] = "item" .. i
|
|
end
|
|
return t
|
|
end
|
|
|
|
print("-- A-1) BAD: nil로 지우면 순서/#t가 불안정해짐 --")
|
|
local bad = buildList(N)
|
|
for i = 2, N, 2 do
|
|
bad[i] = nil
|
|
end
|
|
print("bad #t =", #bad, "(Lua 명세상 정의되지 않은 동작 — 실제 값 확인용)")
|
|
local badOrder = {}
|
|
for i, v in bad do
|
|
table.insert(badOrder, tostring(i) .. "=" .. tostring(v))
|
|
end
|
|
print("bad 순회 순서(구멍이 생겨 흐트러질 수 있음):", table.concat(badOrder, ", "))
|
|
|
|
print()
|
|
print("-- A-2) GOOD: None으로 지우면 #t/순서가 항상 보존됨(PreRef pre-pass에 필요한 성질) --")
|
|
local good = buildList(N)
|
|
for i = 2, N, 2 do
|
|
good[i] = None
|
|
end
|
|
print("good #t =", #good, "(항상 N — 구멍이 없으니까)")
|
|
local goodOrder = {}
|
|
for i = 1, #good do
|
|
local v = good[i]
|
|
goodOrder[#goodOrder + 1] = tostring(i) .. "=" .. (v == None and "None" or tostring(v))
|
|
end
|
|
print("good 순회 순서(1..#t로 직접, 항상 안정적):", table.concat(goodOrder, ", "))
|
|
|
|
-- ===============================================================
|
|
-- Part B. 순서가 안 중요하고 슬롯 재사용이 필요한 배열 — nil이 맞는 경우
|
|
-- (Ref 콜백/대기자 리스트가 실제로 이 카테고리, 2026-08-09 최종 정정)
|
|
-- ===============================================================
|
|
|
|
print()
|
|
print("=== B. Ref 콜백/대기자 리스트: None을 쓰면 무한 성장 버그, nil+재사용이 맞음 ===")
|
|
|
|
-- 등록: table.insert 대신 "빈(nil) 슬롯을 선형 탐색해 재사용"
|
|
local function registerNil(list, value)
|
|
for i = 1, #list + 1 do
|
|
if list[i] == nil then
|
|
list[i] = value
|
|
return i
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 소진: 그 인덱스를 nil로 되돌림(재사용 가능하게)
|
|
local function consumeNil(list, i)
|
|
list[i] = nil
|
|
end
|
|
|
|
-- 대조군: 예전에 잘못 썼던 None 기반 버전(table.insert로만 추가, 소진은 None)
|
|
local function registerNoneBad(list, value)
|
|
table.insert(list, value)
|
|
return #list
|
|
end
|
|
local function consumeNoneBad(list, i)
|
|
list[i] = None
|
|
end
|
|
|
|
print("-- B-1) nil + 슬롯 재사용: 동시 대기자 수만큼만 배열 크기가 유지되는가 --")
|
|
do
|
|
local waiters = {}
|
|
local maxSizeSeen = 0
|
|
-- "등록 -> 곧바로 소진"을 여러 번 반복(:Wait() 호출 후 fire되는 흔한 패턴 흉내)
|
|
for cycle = 1, 1000 do
|
|
local idx = registerNil(waiters, "waiter" .. cycle)
|
|
maxSizeSeen = math.max(maxSizeSeen, #waiters)
|
|
consumeNil(waiters, idx)
|
|
end
|
|
print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(0이어야 함 — 전부 소진됨)")
|
|
print("과정 중 관측된 최대 배열 크기 =", maxSizeSeen, "(작게 유지돼야 함, 이상적으론 1)")
|
|
end
|
|
|
|
print()
|
|
print("-- B-2) None + table.insert(예전 버그): 같은 패턴을 반복하면 배열이 끝없이 자람 --")
|
|
do
|
|
local waiters = {}
|
|
for cycle = 1, 1000 do
|
|
local idx = registerNoneBad(waiters, "waiter" .. cycle)
|
|
consumeNoneBad(waiters, idx)
|
|
end
|
|
print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(1000이어야 함 — 이게 바로 그 버그)")
|
|
local noneCount = 0
|
|
for _, v in waiters do
|
|
if v == None then
|
|
noneCount += 1
|
|
end
|
|
end
|
|
print("그 중 None으로 채워진(죽은) 슬롯 개수 =", noneCount, "(전부 죽은 슬롯인데 자리만 차지)")
|
|
end
|
|
|
|
print()
|
|
print("-- B-3) nil 소진이 순서를 안 깨는가(대기자는 순서 안 중요하지만, 그래도 확인) --")
|
|
do
|
|
local waiters = {}
|
|
registerNil(waiters, "keep-me-1")
|
|
local idx2 = registerNil(waiters, "temp-2")
|
|
registerNil(waiters, "keep-me-3")
|
|
consumeNil(waiters, idx2) -- 중간 슬롯 소진 -> 구멍 생김
|
|
local visited = {}
|
|
for i, v in waiters do
|
|
table.insert(visited, tostring(i) .. "=" .. tostring(v))
|
|
end
|
|
print("구멍 있는 상태에서 순회(전부 방문되기만 하면 충분, 순서 무관):", table.concat(visited, ", "))
|
|
-- 이제 새 등록이 빈 슬롯(구멍)을 재사용하는지 확인
|
|
local reusedIdx = registerNil(waiters, "reused")
|
|
print("새 등록이 빈 슬롯(index=" .. idx2 .. ")을 재사용했는가?", reusedIdx == idx2)
|
|
end
|
|
|
|
--[[
|
|
확인 포인트:
|
|
1. Part A — good(None) 쪽은 #t/순서가 항상 N으로 안정적인가(PreRef
|
|
pre-pass가 요구하는 성질 재확인).
|
|
2. Part B-1 — nil+재사용 방식은 반복해도 배열이 안 커지는가(0 또는
|
|
작은 값 유지)?
|
|
3. Part B-2 — None+table.insert 방식은 실제로 1000까지 자라는가 —
|
|
이게 바로 사용자가 찾아낸 "무한 성장" 버그의 정량적 재현. 이 결과가
|
|
기대와 다르면(예: 실제로는 안 자란다면) ref-plan.md의 정정
|
|
근거 자체를 재검토해야 하니 반드시 알려줄 것.
|
|
4. Part B-3 — 새 등록이 소진된 빈 슬롯(index=idx2)을 실제로 재사용하는가
|
|
— 이게 "table.insert 대신 선형 탐색 재사용 등록 함수"가 실제로
|
|
의도대로 동작하는지의 핵심 확인.
|
|
]]
|