Perry UI 试用

开源项目 Perry 是一个可以通过 LLVM 将 TypeScript 编译成原生二进制可执行文件的构建工具。支持 macOS、Windows、Linux、iOS、Android 等平台。它还提供一个 UI 库,支持多种原生的 UI 组件。

我测试的平台是 Windows,Perry 安装源是 scoop ,版本为 0.5.1665。已用 perry setup windows 安装了 Windows SDK。

Perry 默认将 Windows SDK 安装到 %LOCALAPPDATA%\perry\windows-sdk 目录,且不可更改。可以在安装前将安装路径 Junction 到其他路径。

编译如下最简单的 GUI 代码:

typescriptimport { App, Text } from "perry/ui";

App({
    title: "Perry Test",
    width: 400,
    height: 300,
    body: Text(""),
});
main.ts

编译成功,但是编译出来的 main.exe 启动后立即崩溃退出。下面是 AI 分析的报告。


Perry perry/ui 启动即崩溃:分析与解决方案

1. 环境与现象

项值
OSWindows 10 IoT Enterprise 10.0.19045
编译器Perry 0.5.1665(scoop:D:\Apps\scoop\apps\perry\current)
代码生成clang 23.1.5 / 进程内 LLVM 22.1.8
链接器MSVC link.exe 14.44.35207(VS 2022 Community)
perry doctor全部 OK,含 “runtime library … matches this Perry”
输入main.ts,8 行,与 .perry/types/perry/ui/index.d.ts 的 App/Text 签名一致
tsimport { App, Text } from "perry/ui";
App({ title: "Perry Test", width: 400, height: 300, body: Text("") });

运行 main.exe 立即退出(0xC0000005 / PowerShell -1073741819),Perry 自带崩溃处理器打印:

[CRASH] ACCESS_VIOLATION at code=0x7FF7A8D6FA15 accessing 0xFFFFFFFFFFFFFFFF
[CRASH] rip=… rsp=… module_base=… rip_rva=+0x1AFA15
[CRASH] ACCESS_VIOLATION at code=…+0x82A0 accessing 0xE5F3A00000     ← 崩溃处理器自己也炸了

注意:accessing 0xFFFFFFFFFFFFFFFF 是假的,处理器解析异常记录有误;真实故障地址是 0x9e3779b97f4a7c19。不要被这行误导。

2. 定位过程

2.1 缩小范围:UI 与非 UI 的分野

powershellperry compile probe_noui.ts -o probe_noui.exe   # console.log 版,无 perry/ui
.\probe_noui.exe        # → 正常输出,exit 0

不 import perry/ui 的程序运行正常;import 就一定崩(Text("") 与 Text("Hello") 崩在同一 RVA)。在全新目录 perry init 生成的工程里重复同样程序 → 同样崩在 +0x1AFA15,排除项目配置(空 perry.toml、mise.toml、node_modules/.cache)。

2.2 拿到真实调用栈(cdb + --debug-symbols)

perry compile main.ts -o main_dbg.exe --debug-symbols 会产出 main_dbg.pdb。由于崩溃发生在任何 JS 之前,且 app 自己的 VEH 会先于调试器介入,直接在故障指令处断:

powershell$env:_NT_SYMBOL_PATH = "D:\Projects\perry-demo"
& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe" -G `
  -c "g; r; kn 60; .frame /r 4; q" .\main_dbg.exe
main_dbg!…layout_desc5atoms17seed_static_atoms+0xc5:
00007ff6`50bcfa15 8b4804  mov ecx,dword ptr [rax+4]  ds:9e3779b9`7f4a7c19=????????

 # Child-SP       RetAddr            Call Site
 00 main_dbg!…seed_static_atoms+0x23d0      ← 这是 Perry 崩溃处理器自身二次 AV
 03 ntdll!KiUserExceptionDispatch+0x2e
 04 main_dbg!…layout_desc5atoms17seed_static_atoms+0xc5     ← 真正故障点
 05 main_dbg!js_shape_run_static_seed+0x18
 06 main_dbg!main+0x40
 08 main_dbg!__scrt_common_main_seh+0x10c

崩溃发生在 main 刚进入、执行静态 atom 播种时,早于任何用户代码与窗口创建。

2.3 故障指令在做什么

lea  rcx,[layout_desc::ATOM_START]   ; .perryat 段首
sub  rax,rcx                         ; rax = ATOM_END - ATOM_START
add  rax,-8
mul  …AAAAAAAAAAAAAAAB / shr 4        ; ÷24 → 条目数
…
mov  rsi, 1121                        ; 条目数(实测 rsi=0x461)
loop:
 mov  rax,[r14+8]                     ; entry.descriptor
 test rax,rax ; je next               ; 判空
 cmp  byte [r14+0x10],0 ; jne next    ; 判 flag
 mov  ecx,[rax+4]                     ; ← AV
 add  r14,0x18 ; dec rsi ; jnz loop

即:seed_static_atoms 把整个 .perryat 段当成"从 ATOM_START+8 开始、步长 24 字节的连续数组"。

2.4 段内容对比(决定性证据)

powershellllvm-objdump -s --section=.perryat probe_noui.exe   # 正常
llvm-objdump -s --section=.perryat main.exe         # 崩溃
正常(无 UI,单个标记):        1406a7000 00000000_00000000 | 157c4a7f_b979379e …
崩溃(含 UI,两个标记):        14099d000 00000000_00000000   ← ATOM_START #1 @ +0x00
                                14099d008 00000000_00000000   ← ATOM_START #2 @ +0x08
                                14099d010 157c4a7f_b979379e   ← perry_atom_well_known_iterator

PDB 的 publics 流里能看到两个同名不同 crate hash 的标记:

.perryat+0x00  _RNvNtNtCs7Ovw9KyPL2M_13perry_runtime6object11layout_desc10ATOM_START
.perryat+0x08  _RNvNtNtCs3DdywjVhGST_13perry_runtime6object11layout_desc10ATOM_START
.perryat+0x…   _RNvNtNtCs7Ovw9KyPL2M_13perry_runtime6object11layout_desc8ATOM_END
               _RNvNtNtCs3DdywjVhGST_13perry_runtime6object11layout_desc8ATOM_END

第二个标记把整张表平移 8 字节,于是本该落在 name 槽的 hashbrown 常量 0x9e3779b97f4a7c15(= 1/φ,Rust hashbrown 的乘数)被当作 descriptor 指针读进 rax,[rax+4] 直接访问违规。

3. 根因

3.1 因果链

发布打包:perry_ui_windows.lib 由独立的另一次 cargo 构建产出,内嵌一份自己的 perry_runtime
   (build-script 目录 perry-runtime\88dd33cfd40cf2d1,crate metadata hash Cs7Ovw9KyPL2M)
   而 perry_stdlib.lib / perry_runtime.lib 是 perry-runtime\e05f032d7347befa → Cs3DdywjVhGST
        ↓
两份 perry_runtime 的 mangled name 因 hash 不同而互不冲突 → 链接器视为不同符号,两份都进 exe
        ↓
perry 的链接参数 /WHOLEARCHIVE:<trimmed ui lib> + /FORCE:MULTIPLE(MSVC 下再 /IGNORE:4006)
   → 9227 条 duplicate symbol 被静默吞掉(改用 lld-link 才看得见)
        ↓
本应拦住它的 [strip-dedup] 三条证据链同时失效(见 §4.3)→ bundled runtime 未被剔除
        ↓
.perryat 段头出现两个 ATOM_START → seed_static_atoms 的"整段即数组"假设被破坏,偏移 8 字节
        ↓
把常量当指针解引用 → ACCESS_VIOLATION(在用户代码之前)

3.2 肇事成员

源 archive 里名字完整(perry 裁剪后的产物里才变成匿名 static_N.obj):

perry_runtime-ca03e9dfe259b4d8.perry_runtime.5b05a6a9048d5772-cgu.00.rcgu.o … cgu.15.rcgu.o   (16 个)

在裁剪产物中对应 static_52.obj … static_67.obj,其中 static_67.obj 同时持有 ATOM_START/ATOM_END。这 16 个成员占裁剪后 archive(106 MB)的一半。

4. 已排除的假设(都做过实验)

4.1 应用侧

假设结果
Text("") 空串换成 Text("Hello") 同样崩,同 RVA
项目配置 / 缓存全新 perry init 工程同样崩;--no-cache 无效
环境 / 工具链缺失perry doctor 全 OK

4.2 链接器与 profile(用户先后提出的三个方向)

假设证据结论
MSVC link.exe 的锅用 PERRY_LLD_LINK 换 lld-link(-vv 确认 [link] invoking: …lld-link.exe)无关:.perryat 头部逐字节相同,同 RVA 崩
链接器裁剪 /OPT:REF,ICF手工重链对照(对照组产物与 perry 的 9,820,672 字节完全一致,harness 可信):/OPT:NOREF /OPT:NOICF(66 MB 产物)、交换 stdlib/UI 链接顺序、直接用未裁剪的 11417 成员 archive无关:四种组合全部同崩、同段头
lib 是 debug 还是 release三个 lib 都嵌同一枚 PERRY_RUNTIME_BUILD_STAMP_V1|version=0.5.1665|build=src:cfe1121c7cc9…c11d(与 perry doctor 报的 source 一致);对象路径全在 target\x86_64-pc-windows-msvc\prod\(同一自定义 profile,既非 debug 也非 release);抽出的两份 runtime CGU 均 0 个 .debug$S/.debug$P 段,尺寸同量级(2.99 MB vs 3.29 MB)无关。真正差异在 prod\build\perry-runtime\ 下的目录 hash:UI=88dd33cfd40cf2d1,runtime+stdlib=e05f032d7347befa → 同源码、同 profile,但两次独立 cargo 解析(feature/flags 不同)
libperry_ui_windows.rlib 缺失造了两种"假 rlib":①直接复制 .lib → dedup 过度删除,ma_engine_init 等 undefined,链接失败;②只装真正属于该 crate 的 7 个 CGU → pass 确实活了(rlib found / symbols loaded: 1080 / excluded 137→144),但产物字节数不变、仍两个标记、仍崩、仍 9227 重复是"防线失效的成因之一",但补上 rlib 修不好:符号子集判据永远匹配不上带不同 metadata hash 的副本

4.3 为什么 [strip-dedup] 没能剔除重复

[strip-dedup] rlib NOT found: …\libperry_ui_windows.rlib      ← 无法按 crate 归属识别
[strip-dedup] perry_stdlib.lib symbols loaded: 140648
[strip-dedup] perry_runtime.lib symbols loaded: 0             ← 有证据的符号集为空
[strip-dedup] perry_ui_windows.lib: keeping 1089 of 11417 members
              (excluded: 137 by symbol-subset, 16 by name pattern)

外加:输出 archive 成员被重编号为 static_0.obj … static_1088.obj,“按名字模式"匹配 perry_runtime-*.rcgu.o 结构上不可能命中。perry 二进制里存在 bundled-runtime drop skipped、wrapper symbol rewrite skipped、could not de-duplicate the bundled perry-runtime 等分支——说明这套失效模式它自己写过代码,但在此路径没触发,且完全静默。

4.4 其他

PERRY_DISABLE_WELL_KNOWN / PERRY_FORCE_WELL_KNOWN(编译期与运行期)、--debug-symbols、--no-auto-optimize、PERRY_LL_OPT_LEVEL=0:均无影响。

5. 解决方案

5.1 上游正解(按优先级)

  1. 同批构建:让 perry_ui_windows 与 perry-runtime/perry-stdlib 在同一次 cargo 解析下编译,消除第二份 runtime(现在 88dd33cfd40cf2d1 vs e05f032d7347befa)。
  2. [strip-dedup] 修好 bundled-runtime drop:在源 archive 上按成员名前缀 ^perry_runtime-…-cgu.NN.rcgu.o 直接剔除;发布包补 libperry_ui_windows.rlib;把 rlib NOT found 与 perry_runtime.lib symbols loaded: 0 升为显式 warning 甚至硬失败。
  3. runtime 侧健壮化:seed_static_atoms 应只遍历自己那一段(用各自 marker 精确界定 + 同对齐),而不是"从 ATOM_START+8 起把整段 .perryat 当数组走”——后者对段头新增任何 static 都是零容错的。
  4. 重新评估 /FORCE:MULTIPLE + /IGNORE:4006:正是它把"两份 runtime"这类打包事故从一次红叉的链接失败降级成运行时才炸的隐雷。文档里也应建议用 lld-link 复现以看到 9227 条重复符号。

可直接粘进 issue 的最小复现与证据:perry init 空工程 + 8 行 App({body: Text("hello")}) → 必崩 +0x1AFA15;perry compile … --debug-symbols + cdb → seed_static_atoms+0xc5;llvm-objdump -s --section=.perryat → 段头两个 8 字节标记;PERRY_LLD_LINK=…lld-link.exe … -vv → duplicate symbol: perry_atom_well_known_iterator × 9227。

5.2 本地 workaround(可跑通,但仅限演示/诊断)

原理:把 bundled runtime 的 16 个成员从 perry_ui_windows.lib 的私有副本里删掉,让 exe 只存在一份 runtime。脚本:D:\Projects\perry-demo\workaround.ps1(默认参数已指向本项目,stage 目录 D:\Projects\perry-ui-pruned-rt)。

powershellcd D:\Projects\perry-demo
.\workaround.ps1                       # 或 powershell -NoProfile -File .\workaround.ps1
# 产出 D:\Projects\perry-demo\main_pruned.exe

A/B 实测(同机、同 main.ts、同 perry 0.5.1665):

main.exe(默认)main_pruned.exe(剪枝)
dedup 日志keeping 1089 of 11417keeping 1073 of 11401
链接 duplicate 警告(lld 下可见)922731
.perryat 段头两个 8 字节标记单标记,与控制台程序同形
运行0xC0000005 立即退出存活:窗口 title='Perry Test' rect=400x300,SendMessageTimeout(WM_NULL, 2s) 返回非 0(消息循环在处理)
体积9,820,6729,956,352

workaround 的代价与硬性约束

6. 现场状态与勘误

当前文件:

文件说明
main.ts未改动(10:52)
main.exe被调查过程重写过,见下方勘误
main_pruned.exe剪枝 workaround 产物,可开窗口
workaround.ps1复现 workaround 的脚本(已从探针目录移入项目根)
docs\perry-ui-startup-crash.md本文
perry.toml / mise.toml / .xwin-cache / node_modules未改动(node_modules\.cache\perry 会因编译而更新,属正常)

勘误(重要):调查期间我多次执行 perry compile main.ts -o …,这会顺带重写默认的 main.exe。会话开始时它是 10:52 / SHA256 BCABDF31B1BEB343315E77148A939A90211A9816A8FF5101CE720C56C39209EB;我中途校验时为 12:14 / A8B1F84D2A281D82F3ADDBCD27F28B5593B5600DBC6F462C82C4EBF49E2F5A33,此后(12:20)又被重新生成过一次。大小与行为始终一致(9,820,672、AV @ +0x1AFA15),但原始字节没有备份。我之前数次表述为"main.exe 未改动",那是错的——只比对了大小而未核对哈希。需要原件请用你的版本控制恢复,或视之为等价的重编译产物。

其余现场:Perry 安装目录未被修改(perry_ui_windows.lib 仍 11417 成员 / 109,223,206 字节,已校验);探针目录 D:\Projects\perry-noruntime-probe 与全部中间产物(假 rlib、剪枝 archive、对照 exe、llvm-nm 转储、cdb 日志)已清理,故本文所有证据均以上文引用的输出形式留存,复现命令一并给出。


附件:workaround.ps1