fix(gen): 修 vpi 生成阶段 is_file_func 读到已回收池的悬垂表(ASan 野读崩溃)

现场:TransPyV.exe(ASan)编译 EscapeOkTest 崩在 Phase A-pre-vpi →
_GeneratePyiFromAst → _PyiFunction → compute_escape_summary → _ScanEscapes →
_PushInductionEntry → is_file_func → HashTable._slot_hash
(access-violation on unknown address 0xffffffffffffffff)。

根因(潜伏已久,非本阶段语义改动引起):
- FileClassNames / FileFuncNames / FileVarNames / FileModuleSha1 是按文件重建的表,
  表与键共用该文件的瞬时池;契约要求跨文件/跨阶段先清空再重建
  (原写在 reset_visible_structs 里那四行)。
- Phase A-pre-vpi 为生成 vpi 会重新 lex/parse 每个文件,但不走 register_file_*、
  也不清表 → _PushInductionEntry 的 is_file_func 只认 range 的判据,落在 Phase1
  最后一个文件的表上,而那个池已被 reset_to(mark1) 回收。非 ASan 下常表现为读到
  0/旧值 的静默错答,ASan 下 0xfd 填充 → 垃圾容量/垃圾 __slots__ → 野读崩溃。

修法:
- 抽出 reset_file_name_window() 作为文件级名字窗口的唯一开启入口(原四行清空搬进去,
  reset_visible_structs 改为调它),并在注释里写死契约。
- 新增轻量 register_file_func_names(pool, tree):只重建本文件顶层函数名(核对过 vpi
  路径只需这一张;FileClassNames/FileModuleSha1/FileVarNames 的读取点全在翻译期)。
- Phase A-pre-vpi 循环里,在 _GeneratePyiFromAst 之前调用
  reset_file_name_window() + register_file_func_names(mb, tree_vp2)。
- register_file_classes / register_file_funcs 改为无条件重建(原来 没有才建 的写法在
  表已被别的阶段建过时会静默复用悬垂表)。
This commit is contained in:
TermiNexus committed 2026-10-10 09:41:15 +08:00
1 parent 79d1c86982
commit 41674d125c
3 files changed
+79 -8

No files matched your search

+6
View File
@@ -679,6 +679,12 @@ def RunMultiFileProject(mb: memhub.MemBuddy*, source_dir: str, temp_dir: str, ou
continue
pyi_buf_vp2: bytes = stdlib.malloc(PYI_BUF_SIZE_P2)
if pyi_buf_vp2 is not None:
# 文件级名字窗口:本阶段为生成 vpi 重新解析出 tree_vp2,但**不走** register_file_funcs;
# 而 _PyiFunction → compute_escape_summary → _ScanEscapes → _PushInductionEntry 会问
# is_file_func("range")。不清窗口就会读到"上一个文件的表 + 已被 reset 的池"→
# 悬垂指针(ASan access-violation / 非 ASan 静默错答)。见 StructGenMixin.reset_file_name_window。
GenApi.reset_file_name_window()
GenApi.register_file_func_names(mb, tree_vp2)
pyi_pos_vp2: sizet = StubConverter._GeneratePyiFromAst(mb, tree_vp2, ent_vp2.Path, pyi_buf_vp2, PYI_BUF_SIZE_P2)
if pyi_pos_vp2 > 0:
pyi_path_vp2: str = StubMergerCollect._sliced_path(temp_dir, td_len_vpa, ent_vp2.Sha1, "vpi")
+13
View File
@@ -279,6 +279,19 @@ def register_file_funcs(pool: memhub.MemBuddy*, tree: ast.AST*) -> None:
if _g_gen is not None: _g_gen.StructGen.register_file_funcs(pool, tree)
# ---------------- reset_file_name_window / register_file_func_names ----------------
# "文件级名字窗口"的唯一开启入口 + vpi 落盘路径的轻量重建(契约见 StructGenMixin 两处注释)。
# 任何文件级入口(翻译 / vpi 落盘 / …)都必须先 reset_file_name_window、再重建对应表。
def reset_file_name_window() -> None:
"""清空四张按文件重建的名字表(FileClassNames/FileModuleSha1/FileFuncNames/FileVarNames)"""
if _g_gen is not None: _g_gen.StructGen.reset_file_name_window()
def register_file_func_names(pool: memhub.MemBuddy*, tree: ast.AST*) -> None:
"""只重建"本文件顶层函数名"表(供 vpi 落盘路径的 is_file_func 使用;须先 reset_file_name_window)"""
if _g_gen is not None: _g_gen.StructGen.register_file_func_names(pool, tree)
def is_file_func(name: str) -> int:
"""Return 1 if name is a top-level function declared in this file, else 0."""
if _g_gen is not None: return _g_gen.StructGen.is_file_func(name)
+60 -8
View File
@@ -236,12 +236,8 @@ novirtual class StructGenMixin:
self.StrictVisibility = strict
self.CurDefModuleSha1 = None
self.CurDefClassName = None
# The file-level class name table is rebuilt per file the same way (keys and table share a pool, so no dangling; re-registered by the Translator pre-scan, see register_file_classes).
# FileModuleSha1 points to trans.ModuleSha1 (same-pool string), so it must be cleared too to avoid reading reclaimed memory in the next file.
self.FileClassNames = None
self.FileModuleSha1 = None
self.FileFuncNames = None
self.FileVarNames = None
# 文件级名字窗口:四张按文件重建的表统一由 reset_file_name_window 清空(契约见该方法的注释)
self.reset_file_name_window()
self.ModuleClasses = hashtable.HashTable(pool)
if strict != 0:
# Strict mode: create a new HashTable recording visible structs.
@@ -250,6 +246,26 @@ novirtual class StructGenMixin:
# Lenient mode: everything is visible, no HashTable needed.
self.VisibleStructs = None
# reset_file_name_window - "文件级名字窗口"的**唯一开启入口**:清空四张按文件重建的表。
# 契约:
# · FileClassNames / FileFuncNames / FileVarNames 的表与键**共用该文件的瞬时池**
# (AST 名字是指向 src_buf 的零拷贝指针,故键要拷进同一个池),所以跨文件/跨阶段
# 必须**先清空再重建**;
# · FileModuleSha1 指向 trans.ModuleSha1(同池字符串),同样必须清,否则下一个文件读到被回收的内存。
# ⚠ 任何"文件级入口"都必须在处理该文件前**先调本方法、再重建对应表**;否则
# is_file_class / is_file_func / is_file_var 会拿"上一个文件的表 + 已被 reset 的池"
# = 悬垂指针(ASan 下 access-violation;非 ASan 下静默错答)。
# 现场:Phase A-pre-vpi 只为生成 vpi 重新解析文件、不走 register_file_*,漏开此窗口,
# 于是 _PushInductionEntry → is_file_func("range") 落在 Phase1 已回收的表上,
# HashTable._slot_hash 野读崩溃。
def reset_file_name_window(self) -> int:
"""清空四张按文件重建的名字表(文件级入口必须先调);0=完成"""
self.FileClassNames = None
self.FileModuleSha1 = None
self.FileFuncNames = None
self.FileVarNames = None
return 0
def add_visible_struct(self, pool: memhub.MemBuddy*, name: str) -> None:
"""Record a visible struct name (class definition and from-import)."""
if name is None: return
@@ -307,7 +323,9 @@ novirtual class StructGenMixin:
self.FileModuleSha1 = sha1
ch: list[ast.AST*]* = tree.children
if ch is None: return
if self.FileClassNames is None: self.FileClassNames = hashtable.HashTable(pool)
# **无条件重建**(不是"没有才建"):重建即"关窗口",任何先前阶段(如 Phase A-pre-vpi)
# 留下的表都会被换掉。若沿用 `if is None` 的旧写法,表非空时会**复用**那张可能已随池回收的表。
self.FileClassNames = hashtable.HashTable(pool)
if self.FileClassNames is None: return
cn: sizet = len(ch)
ci: sizet
@@ -330,12 +348,46 @@ novirtual class StructGenMixin:
if name is None or self.FileClassNames is None: return 0
return self.FileClassNames.__contains__(name)
# register_file_func_names - **只**重建"本文件顶层函数名"集合(vpi 落盘路径专用)。
# Phase A-pre-vpi 会为每个文件重新 lex/parse 出独立 AST 再生成 vpi,但不走 register_file_funcs;
# 而 compute_escape_summary 的归纳变量判据要问 is_file_func("range")
# ("本文件自己定义过 range 就放弃区间推断",见 _PushInductionEntry)。
# 核对过消费者:vpi 路径**只需要这一张表**——FileClassNames / FileModuleSha1 / FileVarNames 的
# 读取点(TypeGenResolve 的类型归属 / AssignGenMixin 的全局写判据)全部在翻译期;
# 另一个 is_file_func 调用点(VpiReader 注入自家 declare 的守卫)标注为 full-translation only。
# 调用方必须**先 reset_file_name_window**(本方法只负责"重建"那一半),否则会与既有表叠加。
def register_file_func_names(self, pool: memhub.MemBuddy*, tree: ast.AST*) -> None:
"""重建"本文件顶层函数名"表(供 vpi 落盘路径的 is_file_func 使用)"""
if pool is None or tree is None: return
ch: list[ast.AST*]* = tree.children
if ch is None: return
self.FileFuncNames = hashtable.HashTable(pool)
if self.FileFuncNames is None: return
cn: sizet = len(ch)
ci: sizet = 0
while ci < cn:
child: ast.AST* = ch.get(ci)
ci += 1
if child is None or child.kind() != ast.ASTKind.FunctionDef: continue
fd: ast.FunctionDef* = ast.FunctionDef*(child)
if fd is None or fd.name is None: continue
if self.FileFuncNames.__contains__(fd.name) != 0: continue
# 键必须与表同池(AST 名字指向 src_buf 的零拷贝指针,跨阶段可能失效)——同 register_file_classes
nm_len: sizet = string.strlen(fd.name)
nm_buf: str = pool.alloc(nm_len + 1)
if nm_buf is None: continue
string.strcpy(nm_buf, fd.name)
self.FileFuncNames.set_int(nm_buf, 1)
def register_file_funcs(self, pool: memhub.MemBuddy*, tree: ast.AST*) -> None:
"""Pre-register the top-level function names of this file."""
if pool is None or tree is None: return
ch: list[ast.AST*]* = tree.children
if ch is None: return
if self.FileFuncNames is None: self.FileFuncNames = hashtable.HashTable(pool)
# **无条件重建**:同 register_file_classes——重建即"关窗口",避免复用上一阶段/上一文件
# 留下的、其池可能已被 reset 的表(旧写法 `if is None` 在这种情形下会静默复用悬垂表)。
self.FileFuncNames = hashtable.HashTable(pool)
self.FileVarNames = None # 本函数的后续循环里按需重建(每文件一次)
if self.FileFuncNames is None: return
# 生命期摘要表:**只分配一次**(PermPool,对照 init_struct_table 的长生命周期表口径),
# 此后每文件仅重置 EscCount。表内只存 u64 名哈希,且读只发生在 [0, EscCount) 区间;