test(lifetime): 新增 NewLifetimeTest/NewLifetimeFreeTest 与 IR 断言,escape 跑法纳入新项目

- NewLifetimeTest(正向,9 用例自检 + 退出码判定):出口释放 / 循环内覆盖释放 / 别名只释放一次 / 嵌套函数登记区间隔离 / early return / void 裸 return / 仅分支里 new 时 free(NULL) / stack slife 不释放。

- NewLifetimeFreeTest(负向):对 slife 对象手动 stdlib.free → LifetimeError。

- SequentialRegressionTest 增 IR_EXPECT:编译成功后断言生成的 IR 出现 llvm.call @free(漏释放=纯泄漏,退出码看不出来);--escape/--only 纳入两个新项目,run_lifetime_negative_tests.py 同步。
This commit is contained in:
TermiNexus committed 2026-10-08 20:04:12 +08:00
1 parent 9eb037ee8f
commit 7451adf523
8 files changed
+301 -11

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
from stdint import *
import w32.win32console
import t, c
from t import CInt, CExport
import stdlib
# =============================================================================
# 负向工程(**应当编译失败**,对照工程:NewLifetimeTest)
#
# §4.3 判据 5:`slife` 对象由编译器独占释放 —— 禁止手动 free。
# 理由:出口已经会自动 free 一次,再手动 free 就是 double-free。
# 期望报错:LifetimeError。
# =============================================================================
novirtual class Point:
x: int
y: int
def main() -> CInt | CExport:
w32.win32console.SetConsoleCP(65001)
w32.win32console.SetConsoleOutputCP(65001)
p: Point* = new heap slife Point(1, 2)
stdlib.free(p) # ← 必须被拒:slife 由编译器在函数出口释放
return 0
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://raw.githubusercontent.com/TermiNexus/TransPyC/main/schemas/project-schema.json",
"name": "NewLifetimeFreeTest",
"version": "1.0.0",
"source_dir": "./App",
"build_dir": "./.tpv_build",
"compiler": {
"cmd": "llc",
"flags": ["-filetype=obj", "-relocation-model=pic"]
},
"linker": {
"cmd": "clang++",
"flags": ["-lmsvcrt", "-lucrt", "-lpthread", "-lmingwex", "-lkernel32"],
"output": "NewLifetimeFreeTest.exe"
},
"includes": [
"../../includes"
],
"target": {
"triple": "x86_64-pc-windows-gnu",
"datalayout": "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
},
"options": {
"slice_level": 3,
"sha1_slice_level": 1,
"target": "llvm",
"strict_mode": true
}
}
+131
View File
@@ -0,0 +1,131 @@
from stdint import *
import w32.win32console
import t, c
from t import CInt, CExport
import stdio
# =============================================================================
# M3 owned slife:`new heap slife …` 的 RAII 自动释放(函数级出口统一 free)
# 设计见 Debugger/ownership-and-new.md §4.2(释放点)/ §4.3(硬约束)。
#
# 语义:`new heap slife` 得到 malloc 的对象,**由编译器独占释放**——在引入该绑定的
# 最内层作用域出口(函数的所有 return / 隐式收尾)`free` 它;赋值处若已有旧对象则
# 先"覆盖释放"(循环里反复 new 因此不累积);手动 free 被编译期拒绝(见 NewLifetimeFreeTest)。
#
# 本工程是**正向**工程:编译必须成功、运行必须退出码 0。每个 Case 返回 0 才算通过,
# main 累加失败数并在末尾打印结论。
#
# IR 断言:本工程**刻意不写任何用户级 free**(否则断言会被用户代码蒙混过关)。
# SequentialRegressionTest 的 IR_EXPECT 会检查生成的 IR 里出现 `llvm.call @free`
# (temp/**.ll 是 LLVM dialect 文本,调用写作 `llvm.call @free(%9) : (!llvm.ptr) -> ()`),
# 那只能来自 M3 的出口释放——缺释放(泄漏)是运行期看不出来的,必须直接断言 IR。
# =============================================================================
novirtual class Point:
x: int
y: int
# (1) 普通出口:字段在释放前可读,函数返回时 free(p)
def CaseBasic() -> int:
p: Point* = new heap slife Point(1, 2)
if p.x != 1: return 1
if p.y != 2: return 2
return 0
# (2) 循环内反复 new:靠"覆盖释放"不累积。
# 第二次赋值前必须先 free 旧值(缺了=泄漏,多了/漏判=double-free 崩)
def CaseLoop(n: int) -> int:
i: int = 0
while i < n:
p: Point* = new heap slife Point(i, i + 1)
if p.x != i: return 1
i += 1
return 0
# (3) 别名:q 只是借用 p 的对象,出口必须只释放一次(释放两次会 abort)
def CaseAlias() -> int:
p: Point* = new heap slife Point(3, 4)
q: Point* = p
if q.x != 3: return 1
return 0
# (4) 真·嵌套函数:内层在编译期与外层共享同一张登记表,必须各自隔离——
# 内层出口只能释放内层对象(否则外层对象被提前/重复释放)
def CaseNestedDef() -> int:
a: Point* = new heap slife Point(5, 6)
def Inner() -> int:
b: Point* = new heap slife Point(7, 8)
return b.y
r: int = Inner()
if r != 8: return 1
if a.x != 5: return 2
return 0
# (5) 多出口:early return 的两条路径都要释放。
# 注意返回**错误码**而不是字段值:成功分支若 `return p.x`(=9),main 里的 `fails +=`
# 会把它当成失败数累加(现场踩过:19 = 9 + 10,看着像 19 个用例错了,其实全过)。
def CaseEarlyReturn(flag: int) -> int:
p: Point* = new heap slife Point(9, 10)
if flag != 0:
if p.x != 9: return 1
return 0
if p.y != 10: return 2
return 0
# (6) void 函数 + 裸 return:裸 return 与隐式收尾两条出口都要释放
def CaseVoidBare(flag: int) -> None:
p: Point* = new heap slife Point(11, 12)
if flag != 0:
return
stdio.printf("void-slife x=%d\n", p.x)
# (7) 只有分支里才 new:flag=0 时该语句运行期不执行,出口必须 free(NULL)。
# 缺"入口块置空"就会 free 栈垃圾 → 堆损坏/abort
def CaseBranchOnly(flag: int) -> int:
p: Point* = None
if flag != 0:
p = new heap slife Point(13, 14)
if p.x != 13: return 1
return 0
# (8) stack slife:帧内 alloca,随帧消失,**不得**释放(对栈地址 free 会 abort)
def CaseStack() -> int:
p: Point* = new stack slife Point(15, 16)
if p.x != 15: return 1
return 0
def main() -> CInt | CExport:
w32.win32console.SetConsoleCP(65001)
w32.win32console.SetConsoleOutputCP(65001)
fails: int = 0
fails += CaseBasic()
fails += CaseLoop(200)
fails += CaseAlias()
fails += CaseNestedDef()
fails += CaseEarlyReturn(1)
fails += CaseEarlyReturn(0)
fails += CaseBranchOnly(0)
fails += CaseBranchOnly(1)
fails += CaseStack()
CaseVoidBare(1)
CaseVoidBare(0)
if fails != 0:
stdio.printf("NewLifetimeTest FAILED, %d case(s) wrong\n", fails)
return 1
stdio.printf("NewLifetimeTest OK: owned-slife RAII cases passed\n")
return 0
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://raw.githubusercontent.com/TermiNexus/TransPyC/main/schemas/project-schema.json",
"name": "NewLifetimeTest",
"version": "1.0.0",
"source_dir": "./App",
"build_dir": "./.tpv_build",
"compiler": {
"cmd": "llc",
"flags": ["-filetype=obj", "-relocation-model=pic"]
},
"linker": {
"cmd": "clang++",
"flags": ["-lmsvcrt", "-lucrt", "-lpthread", "-lmingwex", "-lkernel32"],
"output": "NewLifetimeTest.exe"
},
"includes": [
"../../includes"
],
"target": {
"triple": "x86_64-pc-windows-gnu",
"datalayout": "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
},
"options": {
"slice_level": 3,
"sha1_slice_level": 1,
"target": "llvm",
"strict_mode": true
}
}
+11 -3
View File
@@ -121,8 +121,8 @@
## 一·附、生命期 / 逃逸(escape)相关测试
验证 M1–M2.4 的生命期检查(栈地址逃逸、借用传播、函数逃逸摘要)与 `new` 描述符分配。
因历史原因前 4 个位于**仓库根**(不在 `Test/` 下),`NewTest` 位于 `Test/`:
验证 M1–M3 的生命期检查(栈地址逃逸、借用传播、函数逃逸摘要)、`new` 描述符分配,
以及 `new heap slife` 的 RAII 自动释放(M3)。因历史原因前 4 个位于**仓库根**(不在 `Test/` 下):
| 项目 | 位置 | 期望结果 |
|---|---|---|
@@ -131,6 +131,8 @@
| EscapeMethodTest | 仓库根 `EscapeMethodTest/` | **编译失败**:方法 / `__init__` / 嵌套函数交出栈地址 → `LifetimeError`(三用例互斥,一次开一条) |
| EscapeXmodTest | 仓库根 `EscapeXmodTest/` | **编译失败**:跨模块摘要(`.vpi` 落盘 `# esc=`)路径 → `LifetimeError` |
| NewTest | [Test/NewTest/](NewTest/App/main.vp) | 编译通过并输出 4 行:`stack`(显式 slife) / `heap` / `stack默认slife`(验证**缺省规则**:省略生命期时 stack→slife、heap/memgr→hlife)/ `memgr` |
| NewLifetimeTest | [Test/NewLifetimeTest/](NewLifetimeTest/App/main.vp) | 编译通过、退出码 0(**M3 owned slife RAII**:出口释放 / 循环内覆盖释放 / 别名只释放一次 / 嵌套函数登记隔离 / early return / void 裸 return / 仅分支里 new 时 `free(NULL)` / stack slife 不释放)。**另含 IR 断言**:IR 中必须出现 `llvm.call @free` |
| NewLifetimeFreeTest | [Test/NewLifetimeFreeTest/](NewLifetimeFreeTest/App/main.vp) | **编译失败**:对 `slife` 对象手动 `stdlib.free(p)` → `LifetimeError`(§4.3 判据5,编译器独占释放) |
单独运行(`regression_test.py` 与 `SequentialRegressionTest.py` 都支持):
@@ -138,10 +140,16 @@
python Test/regression_test.py --escape # 只跑上表全部项目
python Test/regression_test.py --only NewTest # 只跑指定项目(逗号分隔可多个)
python Test/SequentialRegressionTest.py --escape --exe <TransPyV.exe> --rebuild-includes
python Test/SequentialRegressionTest.py --only NewLifetimeTest --exe <TransPyV.exe>
```
> `--escape` / `--only` 会在 `Test/<名>` 与**仓库根** `<名>` 两处查找 `project.json` / `project.vpj`,所以根级 escape 项目也能直接跑;`--escape` 会忽略 README 的难度顺序。
**IR 断言**:`SequentialRegressionTest.py` 的 `IR_EXPECT`(项目名 → 生成 IR 中必须出现的子串)在编译成功后、运行前,把 `{build_dir}` 下所有 `.ll` 拼起来做子串检查;找不到 `.ll` 视为断言失败(fail-fast,不静默跳过)。
> `{build_dir}/temp/**.ll` 里是 **LLVM dialect 文本**(mlir-opt 的输入),调用形如 `llvm.call @free(%9) : (!llvm.ptr) -> ()`(不是经典 IR 的 `call void @free`),所以断言串用 `llvm.call @free`(`llvm.func @free` 是声明行,不会被匹配)。
> 之所以加 IR 断言:M3 的"出口释放"只靠"运行没崩"证明不了——**漏了释放是纯泄漏,进程照样退出码 0**;必须直接断言释放调用出现过。
> 因此纳入该断言的项目**不得自己写 free**(否则用户代码的 `free` 会蒙混过关):`NewLifetimeTest` 正是刻意不含任何用户级 `free`。
**负向断言**("应报 `LifetimeError`")需要 TransPyV 编译——`NegativeTest/run_negative_tests.py` 用的是 TPC,而 `new` 描述符与 `LifetimeError` 只在 TPV 侧实现,故单独提供:
```bash
@@ -149,7 +157,7 @@ python Test/run_lifetime_negative_tests.py --exe .\TransPyV\output\TransPyV.exe
python Test/run_lifetime_negative_tests.py --exe .\TransPyV\output\TransPyV.exe --only EscapeMethodTest
```
> 默认跑 `EscapeTest` / `EscapeMethodTest` / `EscapeXmodTest`:断言**编译失败且输出含 `LifetimeError`**;编译成功 = 漏检(FAIL),报错但方向不符 = SKIP。
> 默认跑 `EscapeTest` / `EscapeMethodTest` / `EscapeXmodTest` / `NewLifetimeFreeTest`:断言**编译失败且输出含 `LifetimeError`**;编译成功 = 漏检(FAIL),报错但方向不符 = SKIP。
负向项目在 `SequentialRegressionTest.py` 里是**反向判定**(`NEGATIVE_PROJECTS`):编译失败且输出含 `LifetimeError` 才算通过,且**不再进入运行阶段**;编译成功 = 漏检,直接终止。`regression_test.py --escape` 会**剔除**这些项目(该脚本要求"编译成功 + 能跑"),负向断言请用 `SequentialRegressionTest.py --escape` 或上面的 `run_lifetime_negative_tests.py`。
+66 -3
View File
@@ -36,9 +36,10 @@ OVERVIEW = os.path.join(TEST_DIR, "README.md")
# ============================================================
# 单独运行支持:--escape / --only
# ============================================================
# escape 相关项目(生命期/逃逸检查 + M2.4 new 描述符):
# Test/ 下:NewTest;仓库根下:EscapeTest/EscapeOkTest/EscapeMethodTest/EscapeXmodTest
ESCAPE_PROJECTS = ["EscapeTest", "EscapeOkTest", "EscapeMethodTest", "EscapeXmodTest", "NewTest"]
# escape 相关项目(生命期/逃逸检查 + M2.4 new 描述符 + M3 owned slife RAII):
# Test/ 下:NewTest、NewLifetimeTest(正向)、NewLifetimeFreeTest(负向);
# 仓库根下:EscapeTest/EscapeOkTest/EscapeMethodTest/EscapeXmodTest
ESCAPE_PROJECTS = ["EscapeTest", "EscapeOkTest", "EscapeMethodTest", "EscapeXmodTest", "NewTest", "NewLifetimeTest", "NewLifetimeFreeTest"]
# 负向项目:**期望编译失败**且输出含指定关键字(反向判定,且不运行可执行文件)。
# 这些项目里的用例故意交出本帧栈地址(或跨模块摘要路径),必须被 M1–M2.3 的
@@ -49,6 +50,20 @@ NEGATIVE_PROJECTS = {
"EscapeTest": "LifetimeError",
"EscapeMethodTest": "LifetimeError",
"EscapeXmodTest": "LifetimeError",
# M3 §4.3 判据5:对 slife 对象手动 free → LifetimeError
"NewLifetimeFreeTest": "LifetimeError",
}
# IR 断言:项目名 → 生成 IR 中**必须出现**的子串。
# 动机:M3 owned slife 的"出口释放"只靠"运行不崩"是证明不了的(漏了释放 = 纯泄漏,
# 进程照样退出码 0),必须直接断言生成的 IR 里出现了释放调用。
# 形式注意:`{build_dir}/temp/**.ll` 里是 **LLVM dialect 文本**(mlir-opt 的输入),
# 调用长这样 `llvm.call @free(%9) : (!llvm.ptr) -> ()`,不是经典 IR 的 `call void @free`;
# 用 `llvm.call @free` 才能匹配到调用(`llvm.func @free` 是声明那一行,不会被匹配)。
# 约定:纳入 IR 断言的项目**不得**自己写 free,否则用户代码的 free 会把断言蒙混过关
# (NewLifetimeTest 正是因此不含任何用户级 free)。
IR_EXPECT = {
"NewLifetimeTest": ["llvm.call @free"],
}
@@ -178,6 +193,45 @@ def find_output_dirs(proj_dir, proj_config):
return uniq
def find_ll_files(proj_dir, proj_config):
"""收集项目构建目录下的所有 .ll(TPV 布局:{build_dir}/temp/<xx>/<sha1>.ll)"""
bd = proj_config.get('build_dir') or '.tpv_build'
root = os.path.abspath(os.path.join(proj_dir, bd))
if not os.path.isdir(root):
return []
out = []
for dp, _dn, fns in os.walk(root):
for fn in fns:
if fn.endswith('.ll'):
out.append(os.path.join(dp, fn))
return out
def check_ir_expect(proj_dir, proj_config, name):
"""IR 断言:在生成的 .ll 里检查 IR_EXPECT[name] 的每个子串都出现。
返回 (ok, 说明)。未配置 IR_EXPECT 的项目恒 (True, "")。
找不到任何 .ll 视为**失败**(fail-fast:静默跳过等于没断言)。
"""
wants = IR_EXPECT.get(name)
if not wants:
return True, ""
lls = find_ll_files(proj_dir, proj_config)
if not lls:
return False, f"IR 断言失败:{name} 的构建目录下找不到任何 .ll(检查 build_dir 配置)"
text = ""
for p in lls:
try:
with open(p, 'r', encoding='utf-8', errors='replace') as f:
text += f.read()
except Exception as e:
return False, f"IR 断言失败:读取 {p} 出错: {e}"
missing = [w for w in wants if w not in text]
if missing:
return False, f"IR 断言失败:{name} 生成的 .ll 中未见 {missing}(共 {len(lls)} 个 .ll)"
return True, f"IR 断言通过({len(wants)} 项 / {len(lls)} 个 .ll)"
def run_executable(exe_path):
"""运行单个可执行文件,返回 (returncode, stdout, stderr, timed_out)
@@ -326,6 +380,15 @@ def main():
print(f"\n在 {name} 读取 project.json 失败,立即终止(请修正该文件后重跑)。")
return 1
# ---------- IR 断言(可选,见 IR_EXPECT) ----------
ir_ok, ir_msg = check_ir_expect(proj_dir, proj_config, name)
if not ir_ok:
print(f" [IR] {ir_msg}")
print(f"\n在 {name} IR 断言失败,立即终止。")
return 1
if ir_msg:
print(f" [IR] {ir_msg}")
output_dirs = find_output_dirs(proj_dir, proj_config)
exes = []
for od in output_dirs:
+5 -4
View File
@@ -85,14 +85,15 @@ def find_test_projects():
# ============================================================
# 单独运行支持:--escape / --only
# ============================================================
# escape 相关项目(生命期/逃逸检查 + M2.4 new 描述符):
# Test/ 下:NewTest;仓库根下:EscapeTest/EscapeOkTest/EscapeMethodTest/EscapeXmodTest
ESCAPE_PROJECTS = ["EscapeTest", "EscapeOkTest", "EscapeMethodTest", "EscapeXmodTest", "NewTest"]
# escape 相关项目(生命期/逃逸检查 + M2.4 new 描述符 + M3 owned slife RAII):
# Test/ 下:NewTest、NewLifetimeTest(正向)、NewLifetimeFreeTest(负向);
# 仓库根下:EscapeTest/EscapeOkTest/EscapeMethodTest/EscapeXmodTest
ESCAPE_PROJECTS = ["EscapeTest", "EscapeOkTest", "EscapeMethodTest", "EscapeXmodTest", "NewTest", "NewLifetimeTest", "NewLifetimeFreeTest"]
# 负向项目(**期望编译失败**,见 SequentialRegressionTest.NEGATIVE_PROJECTS):
# 本脚本按"编译必须成功 + 能跑"判定,纳入会误报,故 --escape 时剔除;
# 负向断言请用 SequentialRegressionTest(反向判定)或 run_lifetime_negative_tests.py。
NEGATIVE_PROJECT_NAMES = {"EscapeTest", "EscapeMethodTest", "EscapeXmodTest"}
NEGATIVE_PROJECT_NAMES = {"EscapeTest", "EscapeMethodTest", "EscapeXmodTest", "NewLifetimeFreeTest"}
def find_project_file(proj_dir):
+2 -1
View File
@@ -30,7 +30,8 @@ PROJECT_ROOT = os.path.dirname(TEST_DIR)
# 期望"编译失败 + 报 LifetimeError"的项目(名字;目录优先 Test/<名>,其次仓库根 <名>)。
# 注意:EscapeMethodTest / EscapeXmodTest 内部有多条互斥用例(编译在第一个错误处终止),
# 每个项目只保证"至少触发一条 LifetimeError"。
NEGATIVE_PROJECTS = ["EscapeTest", "EscapeMethodTest", "EscapeXmodTest"]
# NewLifetimeFreeTest:M3 §4.3 判据5(对 slife 对象手动 free)。
NEGATIVE_PROJECTS = ["EscapeTest", "EscapeMethodTest", "EscapeXmodTest", "NewLifetimeFreeTest"]
EXPECT_KEYWORD = "LifetimeError"
COMPILE_TIMEOUT = 300