Electron 打包白屏排查实录:一个 continue_on_error 引发的三天三夜"血案"

0 阅读7分钟

Electron 打包白屏排查实录:一个 continue_on_error 引发的三天三夜"血案"

本文记录了我在开源项目 AionUi(一个统一 AI Agent 图形界面)中遭遇的一次 Electron 打包白屏事故。从发现问题到最终定位根因,历时三天三夜。如果你也在用 Electron + GitHub Actions 做 CI/CD,这篇文章或许能帮你避开一个隐蔽的大坑。

故事的开始:一个"完美"的 CI 构建

那是一个平静的夜晚。

我像往常一样推送了一个版本到 dev 分支,GitHub Actions 开始了它忠实的自动构建工作。十几分钟后,CI 亮起了绿灯——所有平台构建成功。

"不错,今天又是顺利的一天。"

我下载了 macOS 的 DMG,拖进 Applications,双击启动——

白屏。

纯白的、一尘不染的、令人窒息的白屏。像是这个 App 在用行为艺术表达它对这个世界的失望。

没有报错弹窗,没有崩溃提示,就是一片白。

第一天:本地没问题,那一定是 CI 的问题

作为一个经历过无数"在我机器上是好的"名场面的工程师,我的第一反应是——先在本地复现。

npm start  # 本地开发 ✅ 正常
node scripts/build-with-builder.js arm64 --mac --arm64  # 本地打包 ✅ 正常

本地打包出来的 DMG 安装后运行完全正常。

这就尴尬了。经典的"本地好好的,CI 就炸"。

我打开 Electron 的开发者工具日志,终于看到了这个错误:

Not allowed to load local resource:
file:///Applications/AionUi.app/Contents/Resources/app/.webpack/renderer/main_window/index.html

ERR_FILE_NOT_FOUND——Electron 找不到渲染进程的入口文件。这个 index.html 是 webpack 打包生成的,没有它,整个界面就是一片白。

第一个嫌疑人:tar v7

翻看最近的 package.json 变更,我注意到一个依赖升级:

{
  "overrides": {
    "tar": "^7.5.7"  // 从 ^6.2.1 升级
  }
}

tar v7 是一个 breaking change 版本,API 完全重写。而 electron-builder 在打包时需要处理 tar/asar 归档。会不会是 tar v7 导致 asar 打包出了问题?

我花了大半天时间研究 tar v7 的兼容性:

  • 翻了 npm 的 changelog
  • 查了 electron-builder 的源码
  • 对比了 tar v6 和 v7 的 API 差异

结论:tar v7 是无辜的。它修复的是 CVE-2026-23745 安全漏洞,electron-builder 并不直接依赖 tar 的 API。

第一天,白忙。

第二天:深入 asar 的内心世界

既然不是 tar 的问题,那就得看看 CI 打包出来的产物到底长什么样。

我从 CI artifacts 下载了 DMG,挂载后检查 asar:

# 本地打包的 asar
npx asar list /Applications/AionUi-Local.app/Contents/Resources/app.asar | grep index.html
# ✅ .webpack/renderer/main_window/index.html  存在

# CI 打包的 asar
npx asar list /Applications/AionUi-CI.app/Contents/Resources/app.asar | grep index.html
# ❌ 没有任何输出

找到了! CI 打包出来的 asar 里压根没有 index.html

这意味着 webpack 的产物在 CI 环境下根本没有生成。但 CI 构建明明显示成功了啊?

转折点:一条被吞掉的错误

带着疑惑,我仔细翻看了 CI 的构建日志。在数百行日志的角落里,我发现了这个:

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed
- JavaScript heap out of memory

Node.js 内存溢出了!

webpack 在打包过程中吃光了内存,进程直接崩溃。但是——为什么 CI 没有报错?

答案就在 GitHub Actions 的 workflow 配置里:

- name: Build with electron-builder (non-Windows)
  uses: nick-fields/retry@v3
  with:
    timeout_minutes: 80
    max_attempts: 2
    continue_on_error: true  # ← 就是你!
    command: ${{ matrix.command }}

continue_on_error: true——这个设置的本意是:macOS 构建有时会因为 Apple 公证服务超时而失败,为了不阻塞其他平台的构建,设置了继续执行。

但它也悄悄地吞掉了 OOM 错误。webpack 崩溃了,HTML 没生成,但 CI 一脸无事地继续往下走,把一个残缺的产物打包成了 DMG 并上传。

这就像一个建筑工人告诉你"房子盖好了",但实际上里面连墙都没有。

为什么只有 macOS 炸了?

这里有一个有趣的细节:Windows 和 Linux 的构建都正常,唯独 macOS 白屏。

原因在于 Node.js V8 引擎的内存管理机制。V8 有一个人为设定的堆内存上限(64 位系统默认约 4GB),这个限制独立于物理内存。

GitHub Actions 各平台 Runner 虽然标称内存相同(7GB),但实际可用内存差异很大:

GitHub Actions Runner (7GB RAM)
├── macOS 系统开销 (~1.5GB)
├── Xcode / 签名工具 (~0.5GB)
├── npm / 其他进程 (~0.5GB)
└── Node.js 可用 (~4.5GB)
    └── V8 默认堆上限: ~4GB  ← webpack 需要 ~5GB,超了!

macOS 系统本身就比 Linux 更"臃肿",加上 Xcode 工具链、代码签名服务等额外负担,留给 Node.js 的空间更少。当 webpack 打包一个包含 Monaco Editor、Arco Design、多个 AI SDK 的大型 Electron 项目时,内存消耗刚好卡在了 macOS 的临界点上

Windows 和 Linux 刚好没超,macOS 刚好超了。差的可能就是那几百 MB。

这也解释了为什么这个问题之前没出现——随着项目不断壮大,依赖越来越多,webpack 的内存消耗在某个版本终于突破了 macOS 上的 4GB 天花板。

第三天:修复与反思

修复方案

修复本身只需要一行:

env:
  NODE_OPTIONS: "--max-old-space-size=8192"

这不是"给 Node.js 更多物理内存"——Runner 的物理内存还是 7GB,不会凭空变多。

这是解除 V8 引擎的人为限制。告诉 V8:"如果需要,你可以用到 8GB"。实际上 webpack 只会用到它需要的量(约 5GB)。因为 5GB < 8GB(新上限),所以不再触发 OOM。

情况V8 堆上限实际需要系统可用结果
修复前~4GB (默认)~5GB~4.5GBOOM
修复后8GB (手动)~5GB~4.5GB成功

防御性措施

光修 OOM 不够。真正的问题是 continue_on_error: true 让构建失败变成了"沉默的杀手"。

我重写了 macOS 构建步骤,将公证失败构建失败区分开来:

- name: Build with electron-builder (macOS)
  run: |
    set +e
    ${{ matrix.command }} 2>&1 | tee build.log
    BUILD_EXIT_CODE=${PIPESTATUS[0]}

    # 检查 DMG 是否生成
    if ls out/*.dmg 1>/dev/null 2>&1; then
      DMG_EXISTS=true
    fi

    if [ $BUILD_EXIT_CODE -eq 0 ]; then
      exit 0  # 完全成功 ✅
    fi

    # DMG 存在但构建失败 → 可能只是公证问题
    if [ "$DMG_EXISTS" = true ]; then
      if grep -qiE "notariz|staple" build.log; then
        echo "⚠️ DMG 构建成功,但公证失败"
        exit 0  # 允许继续
      fi
    fi

    # DMG 都没生成 → 真正的构建失败
    exit 1  # ❌ 阻断 CI

逻辑很简单:

  • 构建成功 + 公证成功 → 全部通过 ✅
  • DMG 生成了但公证失败 → 警告但不阻塞 ⚠️(用户右键打开即可)
  • DMG 都没生成 → 直接失败 ❌(这才是真问题)

不再一刀切地 continue_on_error,而是精准区分错误类型

番外篇:AionUi 的"混血"打包架构

讲完了 bug 本身,我想聊聊这个 bug 之所以能藏这么深的根本原因——AionUi 的打包流程本身就不走寻常路。

传统开源项目怎么打包?

大多数 Electron 开源项目的打包流程是这样的:

方案 A:纯 Electron Forge
源代码 → electron-forge make → DMG/EXE/DEB
(开发、编译、打包一条龙)

方案 B:纯 electron-builder
源代码 → webpack/vite 编译 → electron-builder → DMG/EXE/DEB
(自己编译,builder 负责打包)

简单直接。一个工具从头管到尾,出了问题也好排查。

AionUi 为什么要"混血"?

AionUi 的打包流程长这样:

源代码
  
Step 1: Electron Forge (webpack 编译)
  ├── WebpackPlugin 编译 main process
  ├── WebpackPlugin 编译 renderer process  index.html   白屏就是这里没生成
  └── 输出到 .webpack/ 目录
  
Step 2: 产物校验
  └── 检查 .webpack/renderer/main_window/index.html 是否存在
  
Step 3: electron-builder (分发打包)
  ├── 读取 electron-builder.yml 配置
  ├──  .webpack/ 打入 asar 归档
  ├── afterPack: 重建原生模块 (better-sqlite3, node-pty...)
  └── afterSign: 代码签名 + Apple 公证
  
DMG / ZIP / EXE / DEB

为什么不直接用一个工具?因为 AionUi 的需求太"拧巴"了:

需要 Electron Forge 的原因:

  • 它的 WebpackPlugin 对 Electron 多进程架构(main + renderer + preload)有开箱即用的支持
  • 开发时的 HMR 热更新、DevServer、日志端口管理都做得很好
  • FusesPlugin 可以在打包时控制 Electron 安全特性(禁用 RunAsNode、启用 Cookie 加密等)

需要 electron-builder 的原因:

  • macOS 代码签名 + Apple 公证(Forge 的 maker 支持有限)
  • 跨架构编译(在 arm64 机器上构建 x64 包,反之亦然)
  • 精细的 asar 控制(哪些模块打包、哪些解压、哪些排除)
  • 多格式输出(DMG + ZIP 同时生成)
  • 更成熟的 CI/CD 集成

单独用 Forge 做不了完善的公证;单独用 electron-builder 又没有 Forge 的 webpack 集成好用。所以 AionUi 用了一个混血方案——Forge 负责编译,electron-builder 负责打包。

混血的代价:两个工具之间的"信任边界"

这个方案的核心脚本是 build-with-builder.js,它充当了两个工具之间的"桥梁":

// Step 1: 让 Forge 编译 webpack
execSync(`npm exec electron-forge -- package --arch=${targetArch}`);

// Step 2: 把 .webpack/ 目录结构整理成 electron-builder 期望的样子
ensureDir(sourceDir, webpackDir, 'main');
ensureDir(sourceDir, webpackDir, 'renderer');

// Step 3: 校验关键产物
if (!fs.existsSync(rendererIndex)) {
  throw new Error('Missing renderer entry');
}

// Step 4: 让 electron-builder 打包
execSync(`npx electron-builder ${builderArgs} --${targetArch}`);

问题就出在这里:Forge 和 electron-builder 之间没有原生的握手机制。Forge 编译完就完了,至于 .webpack/ 目录里到底有没有该有的文件,它不管。electron-builder 拿到 .webpack/ 就打包,至于里面是不是空的,它也不管。

当 webpack 因为 OOM 中途崩溃时,.webpack/ 目录可能是"半成品"——main 进程的代码可能已经编译好了(因为它先编译),但 renderer 的 index.html 还没来得及生成。electron-builder 照样把这个半成品打进了 asar,产出了一个"看起来正常但其实没有界面"的 DMG。

这就是混血架构的代价:两个工具之间的信任边界,恰好是 bug 的藏身之处

与传统方案的对比

维度传统方案 (单工具)AionUi (混血方案)
编译工具内置Electron Forge (WebpackPlugin)
打包同一工具electron-builder
签名/公证工具内置或手动electron-builder + afterSign.js
原生模块工具自动处理afterPack.js 手动重建
错误传播直通,容易发现跨工具,可能被吞掉
灵活性受限于单工具高(可单独定制每个环节)
复杂度

原生模块:另一个深坑

AionUi 不是一个纯 JS 应用。它依赖了多个原生 C++ 模块:

  • better-sqlite3 — 本地数据库,存储对话历史和设置
  • node-pty — 终端模拟,用于运行 CLI AI 工具
  • tree-sitter — 代码解析,用于语法高亮

这些模块必须针对目标平台和架构编译成 .node 二进制文件。在 afterPack.js 中,AionUi 实现了一套完整的跨架构重建逻辑:

// 交叉编译时,先清理错误架构的二进制
if (isCrossCompile) {
  // 删除 build/ 目录(包含错误架构的编译产物)
  fs.rmSync(buildDir, { recursive: true, force: true });
  // 删除对立架构的可选依赖包
  // 比如目标是 arm64,就删除所有 *-x64 的包
}

// 然后为目标架构重新编译
rebuildSingleModule({
  moduleName, moduleRoot,
  platform: electronPlatformName,
  arch: targetArch,
  electronVersion
});

这意味着一次 macOS arm64 构建实际上要经历:webpack 编译 → Forge 打包 → electron-builder 打包 → 原生模块重建 → 代码签名 → Apple 公证,六个步骤。任何一步失败都可能导致最终产物有问题。

为什么不简化?

说实话,我也想简化。但现实是:

  1. Forge 的 maker 不支持 Apple notarytool — 这是硬伤,没法绕过
  2. electron-builder 的 webpack 集成不如 Forge — 特别是多入口(main + renderer + preload + worker)场景
  3. 原生模块的跨架构编译 — 需要精细控制,两个工具各自的方案都不够灵活
  4. 安全特性 — Electron Fuses 只有 Forge 的 FusesPlugin 支持得好

所以这个"混血"方案虽然复杂,但在当前的 Electron 生态下,它是 AionUi 这种重量级桌面应用的实际最优解

代价就是——当 bug 出现在两个工具的"交界处"时,排查难度会指数级上升。就像这次的白屏事故。

经验总结

1. continue_on_error 是一把双刃剑

它能防止非关键失败阻塞流水线,但也能让关键错误悄无声息地溜走。如果一定要用,请确保有额外的产物校验逻辑,而不是无条件信任构建命令的退出码。

2. CI 绿灯 ≠ 构建成功

特别是在 Electron 这种多步骤构建(webpack → electron-forge → electron-builder → 签名 → 公证)的场景下,任何一个环节的静默失败都可能产出一个"看起来没问题但其实不能用"的安装包。

建议:在 build 脚本最后加一个产物校验:

const rendererIndex = path.join(webpackDir, 'renderer', 'main_window', 'index.html');
if (!fs.existsSync(rendererIndex)) {
  throw new Error('Missing renderer entry: .webpack/renderer/main_window/index.html');
}

3. OOM 是一个平台相关的"薛定谔 Bug"

同样的代码,同样的 webpack 配置,在 Windows 不 OOM、在 macOS 就 OOM。它可能今天不出现,明天加了一个依赖就出现了。对于大型 Electron 项目,主动设置 --max-old-space-size 是一个好习惯,不要等到 OOM 了才想起来。

4. 永远验证最终产物

不要相信过程,要验证结果。在 CI 流水线里加一步检查最终产物是否存在且完整,能省去无数个排查白屏的深夜。

写在最后

三天三夜,从怀疑 tar v7、到拆解 asar、到翻遍数百行 CI 日志,最终发现是一个 continue_on_error: true 配合 Node.js OOM 造成的"完美犯罪"。

修复只用了一行配置。但找到这一行的过程,让我深刻理解了一个道理:

最难调试的 bug,不是会报错的 bug,而是假装没有 bug 的 bug。


如果你也在做 Electron 开源项目,或者正在被 CI 打包问题折磨,欢迎关注 AionUi —— 一个将命令行 AI Agent 变成现代聊天界面的桌面应用,支持 Gemini CLI、Claude Code、Codex、通义灵码等多种 AI 工具。

扫码_搜索联合传播样式-白色版.png