《Electron 45.2.3 内核解剖》第 4 章 补丁体系:380 个补丁改了什么
本章的所有数字都来自本仓库的实测统计,不是估算。 复现方式见 4.3 节末尾的脚本;如果你手上是别的 Electron 版本,跑一遍就能得到属于你那份代码的真实分布。
4.0 一个数字的分量
在第 1 章我们数过一次:patches/ 下十七个补丁集,合计 380 个补丁文件,77,519 行 diff 文本。
这个数字很容易被当成"工程量"来感慨一番,然后翻篇。但它真正的含义不是工程量,而是责任的形状。
设想你是一个要把 Chromium 变成一个应用框架的人。你有两条路:
第一条路:fork Chromium,随便改,随便提交。代价是每次上游发版,你要把几十万行的改动 rebase 一遍,而且你改过的地方,上游也在改,冲突会像雪崩一样到来。这条路 Chrome 之外的绝大多数"Chromium 壳浏览器"走过,绝大多数都死在了升级上。
第二条路 :一行上游代码都不改。所有 Electron 自己的逻辑放在 shell/ 目录里,通过 Chromium 已有的扩展点(ContentBrowserClient、ContentRendererClient、WebContentsObserver、mojo 接口)接入。只有当扩展点不够用时,才去打一个补丁。
Electron 走的是第二条路,而那 380 个补丁,就是"扩展点不够用"的全部清单。
所以这 380 个补丁构成了一份极其罕见的文档:它精确记录了 Chromium 作为"浏览器"和作为"应用框架底座"之间,到底差在哪 380 个地方。
这份文档的价值在于,它是被动产生的。没有人坐下来设计"我们需要在这 380 个点开洞",每一个补丁都是某一次真实需求撞墙之后留下的凿痕。撞墙的人写下了补丁的 commit message,说明为什么非改不可。这些 commit message 加在一起,比任何架构文档都诚实。
本章要做的,就是把这 380 个凿痕按"它们各自在解决什么问题"重新排列一遍。排完之后你会发现一件事:它们不是均匀分布的,而是密集地聚在九个地方。这九个地方,就是 Electron 与 Chromium 的接缝所在。
先做个预告,这九个地方是:
- 构建系统(3 个补丁,但决定一切)
- 窗口创建链(浏览器侧如何拦截"网页要开新窗口"这件事)
- V8 平台的所有权 (谁来
InitializePlatform) - 渲染进程的脚本上下文生命周期 (
unload什么时候跑) - WebContents 的视图注入(OSR 的入口)
- 进程单例与多实例通信
- Linux 沙箱的混合模式
- Node.js 的构建与模块系统
- macOS App Store 的私有 API 清理
后面每一节展开一个。但在这之前,得先把"补丁系统"这台机器本身拆开看清楚------因为不理解它怎么运转,就无法理解为什么某些补丁必须写成那个样子。
4.1 三条合法性理由:官方给自己上的紧箍咒
patches.md 是 Electron 仓库里最短也最重要的文档之一。它开头就把立场摆得很硬:
Every patch in Electron is a maintenance burden. When upstream code changes, patches can break---sometimes without even a patch conflict or a compilation error. It's an ongoing effort to keep our patch set up-to-date and effective. So we strive to keep our patch count at a minimum.
注意这句话里最阴险的部分:"sometimes without even a patch conflict or a compilation error"。
一个补丁可能在你升级 Chromium 之后:
- 依然能 apply(上下文没变)
- 依然能编译通过(函数签名没变)
- 但语义已经错了
举个例子。假设上游把一个函数的默认参数从 true 改成 false,而你的补丁在调用处显式传了 true------patch 干净应用,编译通过,行为静默改变。这类故障是最贵的,因为它不会在 CI 的构建阶段暴露,只会在用户机器上以"某个功能偶尔失效"的形式出现。
正因为如此,文档给每个补丁规定了三条必须满足的理由,任选其一:
理由一:临时性补丁。
The patch is temporary, and is intended to be (or has been) committed upstream or otherwise eventually removed. Include a link to an upstream PR or code review if available, or a procedure for verifying whether the patch is still needed at a later date.
这类补丁的正确形态是"带着死亡日期出生"。它必须附上上游 PR 链接,或者附上一个"怎么验证它还需要不需要"的流程。
理由二:Electron 专有的编译适配。
The patch allows the code to compile in the Electron environment, but cannot be upstreamed because it's Electron-specific (e.g. patching out references to Chrome's
Profile). Include reasoning about why the change cannot be implemented without a patch (e.g. by subclassing or copying the code).
这条特别值得玩味。它强制作者回答一个问题:"你为什么不能继承一下?"
Chromium 的大量代码依赖 Profile(浏览器用户配置)、PrefService(偏好设置服务)、BrowserProcess(Chrome 的全局单例)。Electron 没有这些东西------一个 Electron 应用没有"用户配置文件"的概念,app.getPath('userData') 背后只是一个目录。
所以 Electron 引入 Chromium 的某些组件时,那些 Profile* 参数会变成 nullptr,PrefService::GetBoolean(...) 会变成编译掉的代码。这类改动"不可能上游化",因为上游需要那个 Profile。但"不可能上游化"不等于"可以随便打补丁",文档要求你说清楚为什么不能用继承或者复制代码绕开。
理由三:功能上根本对立。
The patch makes Electron-specific changes in functionality which are fundamentally incompatible with upstream.
这是最强的一条,也是最少的。典型例子是 can_create_window.patch------Electron 需要在自己创建窗口之前拦截并可能否决,而 Chromium 的窗口创建流程是围绕 Chrome 的 WebContentsDelegate 设计的,Electron 的 delegate 语义不同。
三条理由之外,文档还给了一句价值判断:
We should aim to upstream changes whenever we can, and avoid indefinite-lifetime patches.
"尽量避免无限生命周期的补丁。"这句话解释了为什么 patches/ 里会有 127 个 cherry-pick-* 文件------那是反向操作:不是把 Electron 的改动推上去,而是把上游已经修好的东西提前拿到当前分支上。这类补丁理论上会在下次升级时消失,所以它是"有死亡日期"的。
到这里你应该已经感觉到这套体系的性格了:它把补丁当成债,每一笔债都要写明用途、还款计划、以及为什么不能现在就还。
这个性格直接塑造了补丁的物理形态。
4.2 补丁系统的机械结构
4.2.1 三层目录
erlang
patches/
├── config.json ← 声明"哪个补丁集打到哪个仓库"
├── chromium/
│ ├── .patches ← 241 行,声明应用顺序
│ ├── build_gn.patch
│ ├── can_create_window.patch
│ └── ...(241 个)
├── node/
│ ├── .patches ← 58 行
│ └── ...
├── v8/ ← 35 个
├── nan/ ← 11 个
├── angle/ ← 8 个
└── ...
config.json:2-6 是唯一的全局索引,它是一个 17 元素的数组:
json
[
{ "patch_dir": "src/electron/patches/chromium", "repo": "src" },
{ "patch_dir": "src/electron/patches/boringssl", "repo": "src/third_party/boringssl/src" },
{ "patch_dir": "src/electron/patches/ffmpeg", "repo": "src/third_party/ffmpeg" },
{ "patch_dir": "src/electron/patches/v8", "repo": "src/v8" },
{ "patch_dir": "src/electron/patches/node", "repo": "src/third_party/electron_node" },
...
]
注意 repo 字段的值。chromium 补丁集的 repo 是 src------也就是 Chromium 源码树根。这意味着 patches/chromium/*.patch 里的路径是相对于 src/ 的,比如:
css
diff --git a/content/browser/web_contents/web_contents_impl.cc b/...
这一条路径信息很关键。它告诉你:Electron 打补丁的对象是 Chromium 的 content/ 层和 chrome/ 层,而不是 Blink 渲染引擎内部。
我统计了一下 patches/chromium/ 里所有 diff --git 行的顶层目录分布,content/、chrome/、components/、third_party/blink/、build/、gin/、ui/、net/、services/ 这几个前缀覆盖了绝大部分。这个分布本身就是一张"Electron 到底在哪些层面与 Chromium 打交道"的地图,比任何架构图都准。
node 补丁集的 repo 是 src/third_party/electron_node。注意目录名是 electron_node 而不是 node------这是 Electron 在 DEPS 里自己声明的 Node 镜像,不是 Chromium 自带的那份。
v8 补丁集的 repo 是 src/v8。nan 是 src/third_party/nan。squirrel.mac 是 src/third_party/squirrel.mac,而 ReactiveObjC 是 src/third_party/squirrel.mac/vendor/ReactiveObjC------一个补丁集嵌在另一个补丁集的仓库里,因为 Squirrel 把 ReactiveObjC vendored 进了自己的目录。
4.2.2 .patches 为什么是文本清单而不是文件名前缀
patches.md 里专门为这个设计辩护过:
Side note: the reason we use a
.patchesfile to maintain the order of applied patches, rather than prepending a number like001-to each file, is because it reduces conflicts related to patch ordering. It prevents the situation where two PRs both add a patch at the end of the series with the same numbering and end up both getting merged resulting in a duplicate identifier, and it also reduces churn when a patch is added or deleted in the middle of the series.
这个理由翻译成人话:
如果补丁叫 001-foo.patch、002-bar.patch,那么当两个人同时提 PR,都想在末尾加一个补丁,他们会各自创建 242-xxx.patch。两个 PR 都合并,就出现了两个 242。更糟的是,如果你在中间插入一个补丁,后面所有文件的编号都要改------一次纯粹的机械重命名,产生 200 个文件的 diff 噪音。
用 .patches 清单,插入一个补丁只需要在文件里加一行。git 的 diff 只会显示那一行。
这个设计决策的代价是:补丁的真实顺序不再能从文件名读出来 ,必须看 .patches。所以你在阅读这个仓库时,ls patches/chromium/ 得到的字母序不是 应用顺序。想按顺序看,必须 cat patches/chromium/.patches。
我第 3 章犯过一次这个错------引用 all.gn:27 里的 read_file 时把 "list lines" 写成了 "list words"。那种错误的根源都是"凭印象读代码"而不是"打开文件读代码"。本章后面所有的路径引用,我都会给出可以直接 grep 验证的形式。
4.2.3 补丁如何变成 git 提交
script/git-import-patches 只有二十来行,核心是三个调用:
git-import-patches:19-22
git-import-patches:19-22
git-import-patches:19-22
git-import-patches:19-22
python
git.import_patches(
repo='.',
patch_data=patch_from_dir(args.patch_dir),
threeway=args.threeway
)
而 patch_from_dir 做的事,是把 .patches 里列出的所有文件内容拼接成一个大字符串 ,交给 git am 一次性消费:
patches.py:31-38
python
def patch_from_dir(patch_dir):
"""Read a directory of patches into a format suitable for passing to
'git am'"""
with open(os.path.join(patch_dir, ".patches"), encoding='utf-8') as file_in:
patch_list = [line.rstrip('\n') for line in file_in.readlines()]
return ''.join([
read_patch(patch_dir, patch_filename)
for patch_filename in patch_list
])
这个"拼接"动作里藏着一个非常巧妙的细节,看 read_patch:
patches.py:14-21
python
def read_patch(patch_dir, patch_filename):
"""Read a patch from |patch_dir/filename| and amend the commit message with
metadata about the patch file it came from."""
ret = []
added_patch_location = False
patch_path = os.path.join(patch_dir, patch_filename)
with codecs.open(patch_path, encoding='utf-8') as f:
for l in f.readlines():
line_has_correct_start = l.startswith('diff -') or l.startswith('---')
if not added_patch_location and line_has_correct_start:
ret.append(f'{PATCH_DIR_PREFIX}{patch_dir}\n')
ret.append(f'{PATCH_FILENAME_PREFIX}{patch_filename}\n')
added_patch_location = True
ret.append(l)
return ''.join(ret)
它在每个补丁文件的第一行 diff - 或 --- 之前,插入两行元数据:
makefile
Patch-Dir: ../../electron/patches/node
Patch-Filename: pass_all_globals_through_require.patch
为什么要插这两行?因为 git am 会把每个补丁变成一个 commit,而 commit message 会原样保留。等到将来用 git-export-patches 反向导出时,脚本可以从 commit message 里读到 Patch-Filename:,从而恢复出原来的文件名。
这是一条完整的往返路径:
lua
.patch 文件 --git am--> git commit(message 里带 Patch-Filename)
^ |
| |
+---- git-export-patches <-----+
没有这两行元数据,导出时就只能从 commit 的 subject 行去猜文件名(git-export-patches 确实也用 subject 推导文件名,但 subject 可能被改动、可能重复、可能含非法字符)。有了这两行,往返是精确的。
4.2.4 refs/patches/upstream-head:区分"谁改的"
git-import-patches 在开始打补丁之前,会先给当前的 HEAD 打一个 ref:
Note that
git-import-patcheswill mark the commit that wasHEADwhen it was run asrefs/patches/upstream-head(and a checkout-specificrefs/patches/upstream-head-<hash>so that gclient worktrees sharing a.git/refsdirectory don't clobber each other). This lets you keep track of which commits are from Electron patches (those that come afterrefs/patches/upstream-head) and which commits are in upstream (those beforerefs/patches/upstream-head).
这个设计的实际用途,在"删除一个补丁"的流程里体现得最清楚:
bash
$ vim src/electron/patches/node/.patches # 删掉那一行
$ cd src/third_party/electron_node
$ git reset --hard refs/patches/upstream-head # 回到纯净上游
$ ../../electron/script/git-import-patches ../../electron/patches/node # 重放剩下的
$ ../../electron/script/git-export-patches -o ../../electron/patches/node
四步里最关键的是第二步 git reset --hard refs/patches/upstream-head。
为什么不能直接 git rebase -i 删掉那个 commit?因为补丁之间可能有依赖:补丁 37 可能修改了补丁 12 引入的函数。如果你只删掉 37,没问题;但如果你要删的是 12,那么 37 就失去了它的基础,rebase 会冲突,而且冲突的解决方式未必和"从头重放"一致。
reset --hard 到上游 + 完整重放,是一个幂等的重建 。它保证结果只取决于 .patches 清单和补丁文件内容,与之前的历史无关。这是补丁系统能长期维护的根本原因。
upstream-head-<hash> 那个变体则解决了一个非常具体的问题:gclient 的 worktree 共享同一个 .git/refs 目录。如果两个 worktree 都用 refs/patches/upstream-head 这一个名字,后跑的会覆盖先跑的,前一个 worktree 再执行 reset --hard 就会跳到错误的位置。加上 checkout 特定的 hash 后缀,每个 worktree 有自己的一份。
这个细节值得停下来想一秒:**一个补丁工具需要处理"多个 worktree 共享 refs"的问题,说明它被设计成可以在多份并行 checkout 上同时工作。**这不是过度设计,而是 Electron 开发者的日常------一个人可能同时有 main 分支的 checkout 和某个 release 分支的 checkout。
4.2.5 冲突解决的三档策略
git-import-patches 提供 -3 参数:
patches.md:89-91
bash
$ cd src/third_party/electron_node
$ git am --abort
$ ../../electron/script/git-import-patches -3 ../../electron/patches/node
-3 传给 git am,启用三方合并。三档策略的递进关系是:
第一档:直接 apply。 上下文完全匹配,补丁原样落地。升级 Chromium 时这种情况占比最高,因为大部分补丁改的是 Electron 自己关心的少数文件,上游很少动。
第二档:三方合并。 上下文行变了,但 git 能从补丁头里的 index abc..def 找到原始 blob,做三方合并。典型场景是上游在补丁附近加了几行代码,或者改了缩进。
第三档:人工解决。 三方合并也失败,git am 暂停,人介入。这时你面对的是真正的语义冲突:上游把那个函数重命名了,或者把那段逻辑挪到了另一个文件。
文档给的处理方式是:
If
git-import-patches -3encounters a merge conflict that it can't resolve automatically, it will pause and allow you to resolve the conflict manually. Once you have resolved the conflict,git addthe resolved files and continue to apply the rest of the patches by runninggit am --continue.
注意"continue the rest of the patches"这个措辞------你手工解决的是一个补丁,后面的补丁还没开始打。这意味着如果你解决的方案改变了某个函数签名,后面依赖它的补丁可能会连锁失败。所以升级时的正确姿势是:从第一个失败的补丁开始,逐个往前推,不要跳。
4.3 全量清点:380 个补丁到底长什么样
现在开始数数。下面这张表是用脚本对本仓库 patches/ 目录做的实测统计:
| 补丁集 | 补丁数 | diff 总行数 | 平均行数 | 目标仓库 |
|---|---|---|---|---|
| chromium | 241 | 48,605 | 201 | src |
| node | 58 | 10,757 | 185 | src/third_party/electron_node |
| v8 | 35 | 5,175 | 147 | src/v8 |
| nan | 11 | 3,866 | 351 | src/third_party/nan |
| angle | 8 | 3,813 | 476 | src/third_party/angle |
| dawn | 4 | 1,537 | 384 | src/third_party/dawn |
| skia | 6 | 1,444 | 240 | src/third_party/skia |
| squirrel.mac | 2 | 815 | 407 | src/third_party/squirrel.mac |
| ReactiveObjC | 2 | 561 | 280 | .../squirrel.mac/vendor/ReactiveObjC |
| pdfium | 3 | 305 | 101 | src/third_party/pdfium |
| boringssl | 2 | 228 | 114 | src/third_party/boringssl/src |
| ffmpeg | 4 | 177 | 44 | src/third_party/ffmpeg |
| minigbm | 1 | 94 | 94 | src/third_party/minigbm/src |
| webrtc | 1 | 87 | 87 | src/third_party/webrtc |
| sqlite | 1 | 31 | 31 | src/third_party/sqlite/src |
| libaddressinput | 1 | 24 | 24 | src/third_party/libaddressinput/src |
| perfetto | 0 | 0 | --- | src/third_party/perfetto |
| 合计 | 380 | 77,519 | 204 |
这张表有三处值得单独说。
第一处:perfetto 补丁集是空的。
.patches 存在但内容为空,patches/perfetto/ 目录下没有补丁文件。为什么还要留这个目录和 config.json 里的那一行?
因为 Node 那边的 perfetto 支持需要它。patches/node/.patches 的第 44 到 51 行是一个八行的窗口,里面有六个 perfetto 相关补丁:
44 build_perfetto-sdk.patch
45 lib_add_perfetto_support.patch
46 src_fix_trace_macro_compatibility.patch
47 src_add_perfetto_trace_agent.patch
48 src_fix_perfetto_build_on_gettracefilepath.patch
49 src_test_disable_trace_events_tests_when_perfetto_is_enabled.patch
50 test_account_for_libc_rm_error_on_electron_linux.patch ← 非 perfetto
51 fix_use_embedder_perfetto_client_when_available.patch
52 src_fix_external_reference_registration_for_new_cfunction_lifetime.patch ← 非 perfetto
注意第 46 行的 src_fix_trace_macro_compatibility.patch 名字里没有 "perfetto",但它改的是 trace 宏的兼容性------因为切到 Perfetto 之后,V8 和 Node 的旧式 TRACE_EVENT 宏需要适配。
这组补丁把 Node 的 --trace-event-enabled 从 Node 自己的 tracing 实现切到 Chromium 的 Perfetto。而 Chromium 的 Perfetto 本身在 Electron 里需要一些调整------当前版本恰好没有,所以补丁集为空。但目录和 config 条目保留,因为这个位置是预留的:一旦上游 Perfetto 的某个改动需要回退,补丁就落在这里。
空目录比"到时候再建目录"省事,因为 config.json 是一份需要保持和 CI 脚本一致的清单,加一行牵扯的东西比想象中多。
第二处:nan 的 11 个补丁里有一个 2,711 行的怪物。
nan/chore_add_yarnrc_yml_and_yarn_lock_file_to_use_yarn_v4.patch,2,711 行,只改 2 个文件:.yarnrc.yml 和 yarn.lock。
一个补丁改 yarn.lock,意味着什么?意味着这个补丁在升级 nan 时一定会冲突,因为 lockfile 是最容易变的东西。
为什么还要这么做?因为 nan 是 Electron 里少数几个靠 npm/yarn 生态构建的依赖,而 Electron 主仓用 Yarn 4(packageManager 字段指定),nan 上游可能还在用旧版本。如果不打这个补丁,构建 nan 时会因为 Yarn 版本不匹配而失败或者产生不可复现的依赖树。
这是一个典型的"丑陋但必要"的补丁:没有技术含量,纯粹为了让构建跑起来。它同时也是补丁系统的一个反例------这类补丁应该尽快上游化或者用构建参数绕过,否则每次升级都是手工解决 lockfile 冲突。
第三处:平均行数 204 这个数字会骗人。
204 行的平均值,掩盖了一个极端的长尾分布。我按行数排序取了头尾:
| 行数 | 文件数 | 说明 |
|---|---|---|
| 6,504 | 41 | revert_oscrypt_remove_sync_backend.patch |
| 2,711 | 2 | nan/chore_add_yarnrc_yml...yarn_v4.patch |
| 2,538 | 68 | mas_avoid_private_macos_api_usage.patch.patch |
| 1,983 | 20 | angle/cherry-pick-c553b337ecaf.patch |
| 1,327 | 13 | cherry-pick-9c58c948fa47.patch |
| 1,131 | 19 | node/src_add_perfetto_trace_agent.patch |
| 991 | 13 | printing.patch |
| 879 | 34 | node/api_remove_deprecated_getisolate.patch |
| 845 | 15 | feat_configure_launch_options_for_service_process.patch |
| 811 | 7 | cherry-pick-63c916f7b401.patch |
再看另一端:
| 行数 | 文件数 | 说明 |
|---|---|---|
| 20 | 1 | build_libc_as_static_library.patch |
| 20 | 1 | node/api_delete_deprecated_fields_on_v8_isolate.patch |
| 19 | 1 | node/reland_temporal_unflag_temporal.patch |
| 22 | 1 | build_gn.patch |
| 22 | 1 | patch_osr_control_screen_info.patch |
| 22 | 1 | fix_export_zlib_symbols.patch |
| 23 | 1 | gritsettings_resource_ids.patch |
| 24 | 1 | chore_remove_reference_to_chrome_browser_themes.patch |
最短的补丁是 19 行,最长的 6,504 行,相差 342 倍。
而这个 19 行的补丁------node/reland_temporal_unflag_temporal.patch:4-16,只有 1 行添加、0 行删除------可能比 6,504 行的那个更重要,因为它决定 Temporal API 在不在。
这就是为什么"按行数评估补丁重要性"是错的。行数是维护成本 的粗略代理,不是影响力的代理。
统计脚本
上述数字可以这样复现(在仓库根目录执行):
python
import os
for d in sorted(os.listdir('patches')):
p = os.path.join('patches', d, '.patches')
if not os.path.exists(p):
continue
names = [l.strip() for l in open(p) if l.strip()]
lines = 0
for n in names:
f = os.path.join('patches', d, n)
if os.path.exists(f):
lines += sum(1 for _ in open(f, encoding='utf-8', errors='ignore'))
print(f'{d:20} {len(names):4} {lines:8}')
想统计单个补丁的增删行数与影响文件数:
python
import os
rows = []
for d in os.listdir('patches'):
p = os.path.join('patches', d, '.patches')
if not os.path.exists(p):
continue
for n in [l.strip() for l in open(p) if l.strip()]:
f = os.path.join('patches', d, n)
if not os.path.exists(f):
continue
txt = open(f, encoding='utf-8', errors='ignore').read()
lines = txt.count('\n')
adds = sum(1 for l in txt.split('\n') if l.startswith('+') and not l.startswith('+++'))
dels = sum(1 for l in txt.split('\n') if l.startswith('-') and not l.startswith('---'))
files = len({l.split(' b/')[-1] for l in txt.split('\n') if l.startswith('diff --git')})
rows.append((lines, adds, dels, files, d, n))
rows.sort(reverse=True)
for r in rows[:20]:
print(f'{r[0]:7}{r[1]:7}{r[2]:7}{r[3]:7} {r[4]}/{r[5]}')
注意 adds / dels 的统计要排除 +++ / --- 开头的行,否则每个文件头都会被算成一次增删。这是统计 diff 时最常见的坑。
4.4 按动机分类:380 个补丁的"为什么"
行数告诉我们成本,文件名前缀告诉我们的是意图。我按文件名第一个下划线前的词做了分类:
| 前缀 | 数量 | 含义 |
|---|---|---|
cherry-pick |
127 | 从上游或其它分支提前拿过来的修复 |
fix |
64 | 修复 Electron 侧的 bug |
feat |
27 | 新增能力(通常是给 API 用的底层钩子) |
chore |
24 | 杂项:清理、构建适配、格式 |
build |
20 | 构建系统适配 |
src |
13 | Node 源码层的适配(主要是 V8 API 变更) |
refactor |
7 | 重构上游代码以便 Electron 复用 |
test |
7 | 测试适配 |
revert |
6 | 回退上游的某个改动 |
add |
6 | 纯新增 |
allow |
6 | 放开上游的某个限制 |
expose |
5 | 暴露上游的某个内部接口 |
gin |
3 | gin 绑定层适配 |
| 其它 | 34 | api/disable/support/worker/webhid/electron/reland/pass/remove 等 |
分补丁集看,chromium 和 node 的性格差异非常明显:
chromium(241 个):cherry-pick 72、fix 40、feat 24、chore 15、build 11、revert 6、add 6、allow 6、refactor 6、expose 4。
node(58 个):fix 14、src 12、build 6、test 6、chore 5、api 3、inspector 2。
chromium 那边 cherry-pick 占了近 30%,node 那边几乎没有 cherry-pick,但 src(改 Node 自己的 C++ 源码以适配新版 V8)占了 12 个。
这两个分布差异,恰好对应两个上游的不同状态:
Chromium 是"太快的上游"。 它每六周一个大版本,Electron 的 release 分支要跟。而 Chromium 的安全修复、崩溃修复常常先合到 main,再靠 merge 回 release 分支。Electron 如果需要某个还没 merge 回来的修复,就只能 cherry-pick 那个 commit 做成补丁。
Node 是"太慢的上游"。 Node 的 V8 升级节奏跟随 Chromium,但 Node 自己的代码适配可能滞后。Electron 用的是 Chromium 的 V8(node_v8_path = "//v8",见 all.gn),所以每次 Chromium 升 V8,Node 的 C++ 代码就可能编译不过。那 12 个 src_* 补丁就是补这个时间差。
举几个具体的:
node/src_refactor_module_wrap_cc_to_update_fixedarray_get_params.patch------V8 改了FixedArray::get的参数类型(从int改成uint32_t),Node 的module_wrap.cc传的是int,编译报错或者警告。node/src_stop_using_v8_propertycallbackinfo_t_this.patch------V8 移除了PropertyCallbackInfo::This(),Node 还在用。node/api_promote_deprecation_of_v8_context_and_v8_object_api_methods.patch------V8 把一批 API 标记为 deprecated,Node 需要提前改,否则构建时全是警告(而 Chromium 的构建是-Werror的)。node/src_migrate_to_setter_definer_interceptor_callbacks_returning.patch------拦截器回调的返回值类型变了。
这些补丁的共同特征:**它们不是功能改动,是"让旧代码能在新编译器/新 API 下活下来"。**这类补丁的维护成本极低(上游 Node 迟早会自己改),但生命周期不确定(Node 什么时候改不知道)。
127 个 cherry-pick 的命名规范
看几个实例:
bash
chromium/cherry-pick-9c58c948fa47.patch
chromium/cherry-pick-63c916f7b401.patch
v8/cherry-pick-42d78d0f3d99.patch
angle/cherry-pick-c553b337ecaf.patch
文件名就是 cherry-pick-<上游 commit sha 前 12 位>。这是个非常好的规范,因为它让"这个补丁什么时候能删"变成一次 git show:
bash
# 在上游仓库里
git log --oneline | grep 9c58c948fa47
如果这个 commit 已经在你当前跟随的分支上了,补丁就可以删。这是"理由一:临时性补丁"的最佳实践------把死亡条件编码进文件名。
v8 更极端:35 个补丁里有 31 个是 cherry-pick,剩下的四个是 build_warn_instead_of_abort_on_builtin_pgo_profile_mismatch、src_use_legacy_trace_macros_in_perfetto_to_support_all_phases、feat_key_code_caches_to_the_patch_set_and_the_embedded_blob、fix_allow_embedder_slots_of_non-api_objects_in_snapshot。
也就是说,Electron 对 V8 几乎没有功能性改动,只有 31 个"提前拿修复" + 4 个"构建/追踪/代码缓存/序列化"的必要适配。
这个事实很重要,它回答了很多人对 Electron 的一个误解:"Electron 是不是魔改了 V8?"
不是。Electron 对 V8 的改动集中在构建期,而不是运行时语义。唯一一个真正深入 V8 内部的是最后一个------feat_key_code_caches_to_the_patch_set_and_the_embedded_blob,我们会在 4.13 节专门讲它,因为它牵出了整个代码缓存体系的三层设计。
4.5 根补丁:三行代码如何决定整个构建
现在开始逐个看那些"结构性"的补丁。第一个必须看的是 build_gn.patch:1-8,因为它只有 22 行,却是所有补丁里位置最靠上的一个。
4.5.1 全文
diff
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Ales Pergl <alpergl@microsoft.com>
Date: Thu, 20 Sep 2018 17:44:29 -0700
Subject: build_gn.patch
These are variables we add to the root BUILDCONFIG so that they're available
everywhere, without having to import("//electron/.../flags.gni").
diff --git a/build/config/BUILDCONFIG.gn b/build/config/BUILDCONFIG.gn
index bc4236a4e7080b005e18bacea92fe1d5d71b5cb1..f5c0cc067070bd96caf9dff958bf4f88a10cb91a 100644
--- a/build/config/BUILDCONFIG.gn
+++ b/build/config/BUILDCONFIG.gn
@@ -123,6 +123,9 @@ if (current_os == "") {
declare_args() {
+ is_electron_build = false
+ is_mas_build = false
+
# Set to enable the official build level of optimization. ...
三行:两个变量声明,一个空行。commit message 只有一句话:
These are variables we add to the root BUILDCONFIG so that they're available everywhere, without having to import("//electron/.../flags.gni").
4.5.2 为什么必须改 BUILDCONFIG.gn
GN 的作用域规则是这样的:declare_args() 声明的变量,只在该文件被 import 到的那个作用域里 可见。如果你想在 content/browser/BUILD.gn 里用 is_electron_build,你必须在那文件里 import("//electron/buildflags/flags.gni")。
问题是:content/browser/BUILD.gn 是 Chromium 的文件,Electron 不想在每个需要用到这个标志的 Chromium 文件里都加一行 import------那就是几十个文件的改动,而且每加一处就多一个冲突点。
build/config/BUILDCONFIG.gn 是 GN 的根配置文件 。GN 在解析任何 BUILD.gn 之前,会先加载 BUILDCONFIG.gn,它定义的东西进入全局默认作用域。所以在这里 declare_args() 出来的变量,在整棵源码树的任何 BUILD.gn 里都能直接引用,不需要 import。
这是一个"用最少的改动获得最大可见性"的经典操作:改一个文件的三行,换来全树可用。
4.5.3 这两个变量实际用在哪
我 grep 了整个 patches/ 目录,is_electron_build 出现在 5 个补丁文件里:
ruby
patches/chromium/boringssl_build_gn.patch:16:+if (is_electron_build) {
patches/chromium/build_gn.patch:17:+ is_electron_build = false
patches/chromium/build_resolve_pgo_profiles_from_electron_state_files.patch:37:+ if (is_electron_build && pgo_data_path == "") {
patches/chromium/fix_disable_enable_resource_allowlist_generation_on_windows.patch:32:+ (is_electron_build && (target_os == "linux" || target_os == "mac")) ||
patches/chromium/fix_disable_enable_resource_allowlist_generation_on_windows.patch:33:+ (!is_electron_build && is_official_build &&
patches/chromium/resource_file_conflict.patch:63:+if (!is_android && !is_electron_build) {
patches/chromium/resource_file_conflict.patch:71:+if (is_electron_build) {
五个用处,每个都值得看一眼:
用处一:boringssl 的构建开关。 boringssl_build_gn.patch 里 if (is_electron_build) { ... } 包裹的应该是"Electron 需要但 Chromium 不需要"的 BoringSSL 目标(比如 Node 需要的某些符号)。
用处二:PGO 数据文件位置。 build_resolve_pgo_profiles_from_electron_state_files.patch 里:
gn
if (is_electron_build && pgo_data_path == "") {
PGO(Profile-Guided Optimization)需要一份 .profdata 文件,Chromium 从 Google 的云存储下载。Electron 没有那个权限,也没有那份针对 Chrome 的 profile(Electron 的代码布局和 Chrome 不同,用 Chrome 的 profile 反而可能变慢)。所以这个补丁让 Electron 从自己的状态文件里解析路径。
用处三:资源白名单生成。 fix_disable_enable_resource_allowlist_generation_on_windows.patch 里的条件:
gn
(is_electron_build && (target_os == "linux" || target_os == "mac")) ||
(!is_electron_build && is_official_build && ...)
这是在决定"什么时候生成资源 allowlist"。逻辑是:Electron 在 Linux/macOS 上需要生成,Chromium 只在 official build 上需要。
用处四、五:资源文件冲突。 resource_file_conflict.patch 里:
gn
if (!is_android && !is_electron_build) {
# Chromium 的资源目标
}
if (is_electron_build) {
# Electron 的资源目标
}
这解决的是"Chrome 的 resources.pak 和 Electron 的 resources.pak 同名但内容不同"的问题。在 Electron 构建里,把 Chromium 的资源目标整个关掉,换成 Electron 自己的。
4.5.4 is_mas_build 的另一条命
is_mas_build 是"Mac App Store build"。它的用途集中在 mas_avoid_private_macos_api_usage.patch.patch:2175------注意这个文件名有两个 .patch 后缀,是历史遗留,改不了名,因为改名会破坏 .patches 清单。
这个变量在 4.15 节会详细展开。这里先记住一点:**is_mas_build 不是一个可选优化,是一个编译期硬开关。**因为在 MAS 上使用私有 API 会被 App Store 审核直接拒绝,所以相关的代码路径必须在编译期整个消失,而不是运行时判断。
4.5.5 这个补丁为什么是最脆弱的
build_gn.patch 只有 22 行,但它改的是 BUILDCONFIG.gn 的 @@ -123,6 +123,9 @@ 位置。这个行号是相对的,只要上游在 123 行之前加了超过 3 行,补丁的上下文就会偏移。
更脆的是:BUILDCONFIG.gn 是 GN 的核心文件,Chromium 团队改它的频率不低。每次改动,这个补丁都可能需要重新定位。
但它的失败是安全的失败:apply 冲突会立刻报错,构建停下来。这比那些"apply 成功但语义错了"的补丁好得多。
这就是补丁系统的一个基本规律:改动位置越靠上(越基础),失败越早、越明显;改动位置越靠下(越业务),失败越晚、越隐蔽。
build_gn.patch 属于前者,revert_oscrypt_remove_sync_backend.patch 属于后者。后者会在 4.17 节展开。
4.6 窗口创建链:一次拦截要改四个位置
can_create_window.patch:18-24 是理解"为什么某些功能必须打补丁"的最佳样本。它的 commit message 只有两句话:
This adds a hook to the window creation flow so that Electron can intercede and potentially prevent a window from being created.
It also makes |no_javascript_access| from the embedder suppress the opener, so that the new window is created in its own BrowsingInstance (and process).
但它改了两个文件、四个位置、外加一个 API 签名。
4.6.1 位置一:RenderFrameHostImpl::CreateNewWindow 的参数透传
can_create_window.patch:18-24
diff
@@ -10654,6 +10654,7 @@ void RenderFrameHostImpl::CreateNewWindow(
last_committed_origin_, params->window_container_type,
params->target_url, params->referrer.To<Referrer>(),
params->frame_name, params->disposition, *params->features,
+ params->raw_features, params->body,
effective_transient_activation_state, params->opener_suppressed,
&no_javascript_access);
这里调用的是 ContentBrowserClient::CanCreateWindow。上游的签名没有 raw_features 和 body 两个参数,Electron 加上了。
为什么要加?因为 params->features 已经被解析成了结构化的 WindowFeatures(width、height、left、top、menubar、toolbar......),而 raw_features 是原始的、未经解析的字符串 ,比如 "width=800,height=600,foo=bar"。
Electron 的 window.open 实现需要知道那些 Chromium 不认识的 key。比如 nodeIntegration=yes、webPreferences 相关的自定义 flag------这些在 WindowFeatures 结构体里没有对应字段,解析时就被丢掉了。只有原始字符串能保住它们。
params->body 是 window.open 的第二个参数(POST body)。标准 HTML 里 window.open(url, target, features) 只有三个参数,但 Electron 扩展了它,允许传 body 做 POST。Chromium 的 CreateNewWindow 内部其实也有 body 的传递路径,但没有暴露给 CanCreateWindow 这个钩子。
所以这个改动不是"新功能",是"把已经存在的内部数据往外挪一层"。这类补丁的维护成本低,因为数据本来就在那里,只要上游不改结构体字段名。
4.6.2 位置二:no_javascript_access 反向影响 opener_suppressed
diff
@@ -10664,6 +10665,11 @@
return;
}
+ // A window the opener has no script access to must live in its own
+ // BrowsingInstance, which is exactly what suppressing the opener does.
+ if (no_javascript_access)
+ params->opener_suppressed = true;
+
// Otherwise, consume user activation before we proceed. ...
这个改动看起来很小,但它是一个语义修复,而且注释把因果关系讲得很清楚。
背景:no_javascript_access 是 CanCreateWindow 的出参------委托方(Electron)可以设置它,意思是"这个新窗口不允许 opener 用脚本访问"。
Chromium 原来的行为是:no_javascript_access 只影响后续的 WebPreferences 设置(关掉 javascript_access),但不会改变 opener_suppressed。而 opener_suppressed 才决定新窗口是否进入独立的 BrowsingInstance。
BrowsingInstance 是什么?它是 Chromium 里"共享同一份脚本上下文"的单位。同一个 BrowsingInstance 里的窗口可以互相访问 window.opener;不同 BrowsingInstance 之间的窗口,即使有 opener 引用,也只能拿到一个受限的 WindowProxy。
所以上游的逻辑漏洞是:你告诉我"不要给 opener 脚本访问权",但我仍然把它放在同一个 BrowsingInstance 里,只是把 window.opener 置 null。 这在安全上是不够的------同一个 BrowsingInstance 内,还有别的信道(比如 postMessage 的 targetOrigin 匹配、document.domain 的设置)可以建立联系。
Electron 的修复是把这两件事绑定:no_javascript_access 为真 → 强制 opener_suppressed = true → 新窗口进独立 BrowsingInstance → 物理隔离。
这条改动的意义超出了 Electron 自己。它实际上是在修 Chromium 的一个语义不一致。如果上游接受,这就是一个可以删掉的补丁(理由一)。这也是文档里说的"我们尽量往上推"的典型场景。
4.6.3 位置三:MaybeOverrideCreateParamsForNewWindow
can_create_window.patch:42-47
diff
@@ -5806,6 +5806,10 @@ FrameTree* WebContentsImpl::CreateNewWindow(
create_params.initially_hidden = renderer_started_hidden;
create_params.initial_popup_url = params.target_url;
+ // Potentially allow the delegate to override the create_params.
+ if (delegate_)
+ delegate_->MaybeOverrideCreateParamsForNewWindow(&create_params);
+
// Even though all codepaths leading here are in response to a renderer
// trying to open a new window, ...
这是本次补丁最核心 的一处:在 WebContents::CreateParams 构造完成、但 WebContents 还没实例化之前,插入一个钩子,允许 delegate 修改这些参数。
为什么这个位置关键?
CreateParams 决定了新 WebContents 的几乎所有初始状态:site_instance、browsing_instance、renderer_preferences、web_preferences、initially_hidden、initial_popup_url......
Electron 需要在这个点修改 web_preferences------因为它要在窗口创建时就把 nodeIntegration、contextIsolation、sandbox、preload 这些设置注入到渲染进程的配置里。如果等 WebContents 创建完再改,渲染进程可能已经开始初始化了。
而这个钩子之所以必须打补丁,是因为 Chromium 没有 这个扩展点。上游的 WebContentsDelegate 有 AddNewContents、WebContentsCreated、DidOpenRequestedURL 等回调,但它们都在 WebContents 已经创建之后才触发。上游的设计假设是"新窗口的参数由 Chromium 自己决定,delegate 只负责把它装进某个 UI 容器"。
Electron 的假设不同:"新窗口的参数由应用决定" 。因为应用的 webPreferences 是全局配置 + 窗口级覆盖,必须在创建前就知道。
在 4.1 节的三条理由里,这属于第三条------"功能上根本对立"。Chromium 不可能接受这个钩子,因为对 Chrome 来说,让 delegate 改 CreateParams 是一个巨大的安全面扩张。
4.6.4 位置四:WebContentsCreatedWithFullParams
diff
@@ -5860,6 +5864,13 @@
new_contents_impl->original_window_open_disposition_ = params.disposition;
+ if (delegate_) {
+ // TODO(crbug.com/379869738): Remove GetUnsafeValue.
+ delegate_->WebContentsCreatedWithFullParams(
+ this, render_process_id.GetUnsafeValue(), opener->GetRoutingID(),
+ params, new_contents_impl);
+ }
+
// If the new frame has a name, make sure any SiteInstances ...
以及被删掉的旧回调:
diff
@@ -5901,13 +5912,6 @@
- if (delegate_) {
- delegate_->WebContentsCreated(
- this,
- GlobalRenderFrameHostId(render_process_id, opener->GetRoutingID()),
- params.frame_name, params.target_url, new_contents_impl);
- }
这是一个回调替换,不是新增。
上游的 WebContentsCreated 签名是:
can_create_window.patch:189-194
cpp
virtual void WebContentsCreated(WebContents* source_contents,
int opener_render_process_id,
int opener_render_frame_id,
const std::string& frame_name,
const GURL& target_url,
WebContents* new_contents);
注意它传给 delegate 的只有 frame_name 和 target_url------两个从 params 里摘出来的字符串。而 params 本身(那个包含 raw_features、body、disposition、features 的完整结构)没有传。
Electron 需要完整的 params。因为 window.open 的 features 字符串要用来决定新窗口的初始尺寸、位置、是否显示菜单栏等等------这些信息在 params.features 里,但 WebContentsCreated 只给了 target_url。
所以 Electron 加了一个 WebContentsCreatedWithFullParams,把 params 整个传过去,并且让旧回调不再被调用。
这个"替换"而不是"并存"的选择很讲究。如果两者并存,那么 Chromium 内部的其它 delegate(比如 Chrome 自己的 Browser)会同时收到两个回调,可能导致重复处理。Electron 的写法是:我接管这个位置,旧的不要了。
代价是:如果上游将来给 WebContentsCreated 加参数,这个补丁会冲突。而且冲突的解决不是简单合并------需要判断新参数是否也要透传到 WithFullParams 版本。
4.6.5 TODO(loc): this patch is currently broken.
最后必须提一下这句:
can_create_window.patch:12
kotlin
TODO(loc): this patch is currently broken.
它在 commit message 里,写于 2018 年,2026 年还在。
这是一个诚实到刺眼的标记。它说明:这个补丁的某条路径是坏的,但坏的那条路径不影响当前所有测试和所有生产用法,所以没有被修。
这种标记的价值在于:它阻止了后来者把这个补丁当成"正确实现"来依赖。如果你读代码时发现某个 window.open 场景行为诡异,看到这行 TODO,就知道该往哪里查。
补丁系统里最危险的不是"已知的坏",是"未知的坏"。
4.7 V8 平台所有权:一个 bool 参数改变初始化顺序
gin_enable_disable_v8_platform.patch 的 commit message 只有一句话:
We don't use gin to create the V8 platform, because we need to inject Node things.
这句话背后是 Electron 最深的架构决策之一。我们把它拆开。
4.7.1 gin 原本做什么
gin 是 Chromium 对 V8 的一层薄封装。它负责:
- 创建
v8::Platform(V8 的宿主抽象层:任务调度、内存分配、tracing) - 创建
v8::Isolate(V8 的隔离实例,有自己的堆) - 管理 isolate 的引用表(
ReferenceTable)、数组缓冲分配器(ArrayBuffer::Allocator) - 提供
Wrappable/ObjectTemplate等 C++ ↔ JS 绑定的基础设施
在标准 Chromium 里,gin::IsolateHolder::Initialize 会在第一次调用时创建并注册 V8Platform:
cpp
v8::V8::InitializePlatform(V8Platform::Get());
V8Platform::Get() 返回一个单例,这个单例内部会创建 Chromium 的 TaskRunner 体系来跑 V8 的后台任务(GC、编译、并发标记)。
4.7.2 Electron 为什么不能用它
Electron 的每个渲染进程里,同时跑着两个 JS 运行时:
- Blink 的 V8 isolate(跑页面脚本)
- Node 的 V8 isolate(跑
require进来的模块)
(准确说在 contextIsolation 开启时是同一个 isolate 里的两个 context,在 nodeIntegrationInWorker 等场景下可能是不同 isolate。这里先用"两个运行时"这个简化说法,第 6 章会精确展开。)
关键在于:Node 需要一个自己的 平台层。因为 Node 的 libuv 事件循环要接管 V8 的后台任务调度------node::NodePlatform 会把 V8 的 CallOnWorkerThread、CallDelayedOnWorkerThread 转成 uv 的 async handle。
如果 gin 先创建了 Chromium 的 V8Platform,那么 Node 再想接管就会被拒绝:v8::V8::InitializePlatform 只能调用一次,第二次会 CHECK 失败。
所以 Electron 必须抢在 gin 前面 创建平台。做法是给 Initialize 加一个 bool 参数:
gin_enable_disable_v8_platform.patch:48-54
cpp
void V8Initializer::Initialize(IsolateHolder::ScriptMode mode,
const std::string& js_command_line_flags,
bool disallow_v8_feature_flag_overrides,
v8::OOMErrorCallback oom_error_callback,
bool create_v8_platform) {
...
if (create_v8_platform)
v8::V8::InitializePlatform(V8Platform::Get());
...
}
create_v8_platform 默认 true,所以 Chromium 的其它调用点行为不变。Electron 在自己的初始化路径上传 false,因为在此之前它已经用 node::NodePlatform 创建过平台了。
4.7.3 这个补丁的连带影响
这一个 bool 参数,牵出了 all.gn 里的一行:
all.gn:14
gn
# Electron owns V8 platform initialization, so Node.js must not set up its own.
node_use_v8_platform = false
注意这里的逻辑反转。node_use_v8_platform = false 的意思是"Node 不要自己创建 V8 平台"。但 Electron 明明需要 Node 的平台啊?
不。这里的语义是:Node 不要创建 NodePlatform 并通过 v8::V8::InitializePlatform 注册它;Electron 会自己创建 NodePlatform 实例,然后自己调用 InitializePlatform。
这两件事必须分开,因为注册时机需要精确控制:
- 太早:Chromium 的初始化流程还没准备好,
NodePlatform依赖的TaskRunner还不存在 - 太晚:gin 已经抢注了 Chromium 的
V8Platform
所以 Electron 的做法是:关掉 Node 的自动注册(node_use_v8_platform = false),关掉 gin 的自动创建(create_v8_platform = false),然后在 node_bindings.cc 里手工在正确的时间点做这两件事。
第 6 章会完整走一遍这个时序。这里只需要记住:一个 bool 参数,把"谁来创建 V8 平台"这个所有权问题从编译期默认值改成了运行期决策。
4.7.4 为什么这个补丁不能上游化
gin 是 Chromium 的组件,IsolateHolder::Initialize 的签名被 Chrome、content 层、多个组件调用。加一个带默认值的 bool 参数,对上游是无害但无用 的改动------Chrome 永远传 true。
按 Chromium 的代码审查标准,这种"为下游预留的钩子"通常会被要求改成别的形式(比如在 IsolateHolder 上加一个静态 setter,或者提供一个单独的 InitializeWithoutPlatform 方法)。
Electron 选择直接加参数,是因为这是最小的改动:三个文件、四处签名,没有新增状态。
这个补丁属于三条理由里的第二条------"让代码在 Electron 环境里能编译/能跑,但因为是 Electron 专有的所以不能上游化"。
(顺带说一句:gin 相关的补丁有三个,除了这个还有 chore_restore_some_deprecated_wrapper_utility_in_gin.patch(565 行、13 文件)和另一个。后者的名字很有意思------"恢复 gin 里一些被废弃的 wrapper 工具"。这说明 gin 在某个版本删掉了一批 Electron 还在用的辅助函数。)
4.8 Blink 帧生命周期:unload 到底什么时候跑
blink_local_frame.patch 的 commit message 是本章里信息密度最高的一段:
According to electron/electron#3699, it is unreliable to use |unload| event for process.exit('exit'), so we have to do that in willReleaseScriptContext.
Refs changes in:
- codereview.chromium.org/1657583002
- chromium-review.googlesource.com/c/chromium/...
- chromium-review.googlesource.com/c/chromium/...
This patch reverts the changes to fix associated crashes in Electron.
三句话,讲了一个跨越十年的故事。
4.8.1 问题:process.exit() 在渲染进程里
Node 的 process.exit(code) 在渲染进程里被调用时,需要做几件事:
- 触发
exit事件,让监听者有机会做清理 - 关闭所有打开的 handle(文件、socket、定时器)
- 终止进程
第 2 步里,Chromium 的渲染进程还有一些"页面级"的清理:卸载 DOM、触发 unload / beforeunload 事件、释放 Blink 对象。
Electron 最初的实现是在 unload 事件的处理里跑这些清理。但 issue #3699 报告了问题:unload 事件不保证被触发。
什么时候不触发?
- 页面被强制销毁(
RenderFrameHost被 detach) - 渲染进程崩溃前的强制清理
- 某些导航路径下,旧 frame 直接 detach 而不走 unload
unload 是网页作者可见的事件,它的语义是"页面即将被卸载,你可以在这里做最后的同步工作"。它不是一个可靠的清理钩子,因为 Blink 有权在特定情况下跳过它(性能、安全、崩溃恢复)。
所以 Electron 把清理点挪到了 willReleaseScriptContext------这是 Blink 内部的、保证会调用的回调,在 V8 context 被销毁之前触发。
4.8.2 上游的三次改动
commit message 里列的三个链接,是 Chromium 在 2016 到 2020 年间对这段代码的三次调整:
第一次(2016,codereview.chromium.org/1657583002) :把 Frame::Detach 里的 forbid_scripts 实例化时机往前挪了。上游的理由是"FocusController::FrameDetached() 应该触发 JS 事件,所以那个 client_ 为 null 的检查是多余的"。
第二、三次(2020,2596880 / 2597169):进一步清理了 detach 路径上的脚本禁用逻辑。
这些改动对 Chrome 是对的------Chrome 不需要在 detach 时跑 Node 的清理。但对 Electron 是致命的:它们移除了 Electron 依赖的那个调用时机。
所以这个补丁做了 revert:
diff
bool Frame::Detach(FrameDetachType type) {
...
DCHECK(!IsDetached());
- // TODO(dcheng): FocusController::FrameDetached() *should* fire JS events,
- // hence the above check for `client_` being null. However, when this was
- // previously placed before the `FrameDetached()` call, nothing crashes, which
- // is suspicious. Investigate if we really don't need to fire JS events--and
- // if we don't, move `forbid_scripts` up to be instantiated sooner and
- // simplify this code.
注释被删掉,代码恢复到 2016 年之前的样子。
4.8.3 为什么这是"最脏"的一类补丁
revert 类补丁(本章统计里有 6 个)是维护成本最高的一类。
原因:上游在前进,你在后退。 上游每改一次这段代码,你的 revert 就要重新做一遍,而且每次都要判断"这次的改动里,哪些是我们必须回退的,哪些是可以接受的"。
更麻烦的是:上游的改动通常伴随新功能的依赖。比如上游在 detach 路径上加了一个新调用,你的 revert 把它删了,那么依赖这个调用的新功能就坏了------而你未必知道有那个新功能。
revert 补丁的正确维护方式是:在补丁的 commit message 里写清楚"我们 revert 的是哪个 CL,为什么,以及什么条件下可以停止 revert" 。这个补丁做到了------它列了三个 CL 编号,说了原因是 process.exit 的可靠性。
但它没写"什么条件下可以停"。这是一个缺口。理想情况下应该写:"当 Electron 把清理点从 willReleaseScriptContext 挪到别处之后,这个 revert 可以删除。"
4.8.4 一个更大的教训
这个补丁教给我们的东西,比"Blink 帧怎么 detach"重要得多:
不要依赖网页可见的事件做框架级清理。
unload、beforeunload、DOMContentLoaded、load------这些事件的语义是给网页作者的,它们的触发条件由 HTML 规范定义,而规范允许浏览器在特定情况下跳过它们(尤其是 unload,它在规范里被明确标注为"不可靠")。
框架需要的清理点,必须是引擎保证会调用的内部回调:
- 浏览器侧:
WebContentsObserver::WebContentsDestroyed、RenderFrameHost的RenderFrameDeleted - 渲染侧:
willReleaseScriptContext、blink::ExecutionContext::NotifyContextDestroyed
Electron 在 lib/renderer/init.ts 里注册的 process.on('exit') 清理,也和这个思路一致------不依赖 DOM 事件。
4.9 WebContents 视图注入:OSR 的唯一入口
web_contents.patch:15-22 的 commit message:
This allows overriding the RenderViewHostDelegateView of a WebContents, which is needed for OSR.
Originally landed in github.com/electron/li....
13 行代码,改一个文件:
web_contents.patch:15-22
diff
@@ -4533,6 +4533,13 @@ void WebContentsImpl::Init(const WebContents::CreateParams& params,
params.main_frame_name, GetOpener(), primary_main_frame_policy,
base::UnguessableToken::Create());
+ if (params.view && params.delegate_view) {
+ view_.reset(params.view);
+ render_view_host_delegate_view_ = params.delegate_view;
+ }
+
+ if (!view_) {
+
std::unique_ptr<WebContentsViewDelegate> delegate =
GetContentClient()->browser()->GetWebContentsViewDelegate(this);
@@ -4543,6 +4550,7 @@ void WebContentsImpl::Init(const WebContents::CreateParams& params,
view_ = CreateWebContentsView(this, std::move(delegate),
4.9.1 这两行在做什么
WebContentsImpl::Init 原本的流程是:无条件创建一个 WebContentsView(平台相关的视图,Windows 上是 WebContentsViewAura,macOS 上是 WebContentsViewMac),然后问 ContentClient 要一个 WebContentsViewDelegate。
补丁改成:如果调用方在 CreateParams 里已经给了 view 和 delegate_view,就直接用它们,跳过创建。
CreateParams 里原本就有 view 和 delegate_view 两个字段(它们是被 std::unique_ptr 持有的),但上游的 Init 从来没有读过它们------这两个字段是给别的地方用的,或者是历史遗留。
4.9.2 OSR 为什么要这个
OSR = Off-Screen Rendering。Electron 的 webContents.setFrameRate() + paint 事件,或者 BrowserWindow 的 { webPreferences: { offscreen: true } },让页面渲染到一块内存里的位图,而不是屏幕上的窗口。
这个能力依赖两层:
上层:viz compositor。 Chromium 的合成器把页面合成到 CompositorFrame,然后通过 HostDisplayClient 交给平台层。OSR 需要替换这个 HostDisplayClient,把帧拿到内存里而不是交给窗口系统。这需要 feat_enable_offscreen_rendering_with_viz_compositor.patch(729 行、25 文件)。
下层:RenderWidgetHostView。 这是"渲染结果如何显示"的抽象。正常情况是 RenderWidgetHostViewAura(窗口)或 RenderWidgetHostViewMac(NSView)。OSR 需要一个自定义的 view,它不创建原生窗口,而是把 CompositorFrame 交给 HostDisplayClient。
Electron 的 OSR 实现里有一个 OffScreenRenderWidgetHostView,它实现了 RenderWidgetHostViewBase 接口。要让 WebContents 用它,就必须在 Init 时注入------这就是 web_contents.patch:47 的作用。
4.9.3 render_view_host_delegate_view_ 是什么
补丁的第二行 render_view_host_delegate_view_ = params.delegate_view; 更微妙。
RenderViewHostDelegateView 是一个接口,定义了"渲染视图需要宿主提供什么"。它有这些方法:
cpp
virtual void ShowContextMenu(RenderFrameHost*, const ContextMenuParams&) {}
virtual void StartDragging(...) {}
virtual void UpdateDragCursor(...) {}
virtual void GotFocus(RenderWidgetHostImpl*) {}
virtual void TakeFocus(RenderWidgetHostImpl*, bool reverse) {}
virtual void ShowPopupMenu(...) {}
virtual void ShowLinkPreview(...) {}
注意这些方法的默认实现都是空的。也就是说:如果你不提供 delegate view,这些交互就静默失效。
对一个 OSR 的 WebContents 来说,这是正确的------它没有屏幕,不应该弹右键菜单、不应该有拖拽光标。但如果你的 OSR 应用想自己处理这些(比如在内存里模拟一个右键菜单),你就需要自己的 delegate view。
所以这个补丁实际上做了两件事:
- 允许注入 view(渲染结果的去向)
- 允许注入 delegate view(交互事件的来源)
两者必须同时提供,因为 Init 里的判断是 if (params.view && params.delegate_view)------逻辑与,不是逻辑或。
这个细节很关键。如果你只提供 view 不提供 delegate_view,if 不成立,走回原路径,创建原生 view------你的 OSR 静默失效,而且没有报错。
4.10 单实例:两个补丁构成的一条完整链路
patches/chromium/ 里有两个关于 ProcessSingleton 的补丁,它们必须一起读:
process_singleton.patch("extend ProcessSingleton")feat_add_data_parameter_to_processsingleton.patch("Add data parameter to ProcessSingleton")
4.10.1 第一个补丁:三处平台适配
commit message 列得很清楚:
- It adds a parameter
program_nameto the Windows constructor, making the generated mutex name on the Windows-side program-dependent, rather than shared between all Electron applications.- It adds an
IsAppSandboxedcheck for macOS so that sandboxed applications generate shorter temp paths.- It adds a
ChangeWindowMessageFilterExcall to the Windows implementation, along with a parameteris_app_sandboxedin the constructor, to handle the case when the primary app is run with admin permissions.
第 1 点 解决的是 Windows 上的一个具体 bug。Chromium 的 ProcessSingleton 在 Windows 上用一个命名 mutex 做互斥,mutex 名字来自 user_data_dir 的哈希。但如果两个 Electron 应用用了默认的 userData 路径(或者都没设置),它们会算出同一个 mutex 名,于是第二个应用会以为"已经有一个实例在跑",直接退出。
加上 program_name 参数后,mutex 名变成 program_name + user_data_dir 的哈希,不同应用天然隔离。
第 2 点 是 macOS App Sandbox 的限制。沙箱应用的临时目录路径有长度限制(因为要经过 sandbox 的重定向),Chromium 原本生成的路径可能超长,导致 socket 绑定失败。IsAppSandboxed() 检查后走一条短路径。
第 3 点 是 Windows 的 UIPI(User Interface Privilege Isolation)。当一个普通权限的进程要给高权限进程发窗口消息时,会被 UIPI 拦截。ChangeWindowMessageFilterEx 允许特定消息穿过这个过滤。场景是:主实例以管理员身份运行,第二个实例以普通用户运行,第二个实例要通知主实例"我启动了",这个通知走的是窗口消息,会被拦截。
4.10.2 第二个补丁:把 32 KiB 的数组换成增长缓冲区
这个补丁的 commit message 后半段,是我在本仓库读到的最像工程事故复盘的一段:
Since the payload can be large, the POSIX implementation reads the message into a growable buffer until the client shuts down its end of the socket instead of a fixed 32 KiB array, and the client waits for the socket to become writable rather than giving up, so an oversized message no longer gets truncated or causes the second instance to kill the primary as unresponsive.
拆开看:
问题一:固定 32 KiB 数组。 Chromium 的 POSIX ProcessSingleton 用一个 char buf[32 * 1024] 读消息。如果第二个实例要传的数据超过 32 KiB,会被截断。
问题二:客户端不等可写。 客户端写完 connect 后直接 write,如果 socket 缓冲区满了,write 返回 EAGAIN,客户端就放弃了。
问题三:主实例把慢客户端当成无响应。 主实例在 accept 之后读,如果读不到数据(因为客户端放弃了),会走超时逻辑,视作"这个实例有问题",可能 kill 掉。
三个问题叠加,产生了一个非常恶劣的失败模式:传大数据时,第二个实例不但没能把数据传给第一个实例,还把第一个实例弄死了。
修复方案是标准的 socket 编程:
- 服务端用增长缓冲区,一直读到
read返回 0(对端关闭) - 客户端用
poll等 socket 可写,而不是写完就放弃
这两条改动看起来是"修 bug",但它暴露了一个设计层面的问题:ProcessSingleton 原本假设消息很小(一个命令行),而 Electron 的 requestSingleInstanceLock(additionalData) 允许传任意 JSON。
additionalData 是 Electron 加的参数,可以是一个完整的对象------比如"用哪个文件打开、光标在第几行、选中了哪些内容"。这种数据轻松超过 32 KiB。
所以这个补丁的三条理由里,属于第二条(Electron 专有的功能需求,不能上游化------因为 Chromium 的 ProcessSingleton 永远不需要传大 payload)。
4.10.3 这两个补丁的依赖关系
第二个补丁的 diff 上下文,依赖第一个补丁的存在:
diff
index 7da4c199de1d2721a2e6ba9cb10ba0bc146917b7..b100f5feb95aabf63558aebc4d0054c95b90c4a9
这个 7da4c19... 是第一个补丁应用之后 的 blob hash。也就是说,如果你只应用第二个不应用第一个,git am 会失败------因为上下文对不上。
这就是 .patches 里顺序的意义。process_singleton.patch 必须排在 feat_add_data_parameter_to_processsingleton.patch 之前。
我在 patches/chromium/.patches 里确认了这个顺序,两者挨得很近,中间只隔一个补丁:
59 feat_expose_raw_response_headers_from_urlloader.patch
60 process_singleton.patch
61 add_ui_scopedcliboardwriter_writeunsaferawdata.patch
62 feat_add_data_parameter_to_processsingleton.patch
63 fix_adapt_exclusive_access_for_electron_needs.patch
第 61 行那个 add_ui_scopedcliboardwriter_writeunsaferawdata.patch:4 是插入的。它的 subject 就是 add ui::ScopedCliboardWriter::WriteUnsafeRawData(连 "Clipboard" 都拼错了,Cliboard),commit message 说:
This restores some ability to write to the clipboard using raw formats, which was removed as part of the Raw Clipboard API scrubbing. bugs.chromium.org/p/chromium/...
Chromium 出于安全考虑把 Raw Clipboard API 整个砍了(那个 API 允许网页往剪贴板写任意自定义格式,是攻击面)。Electron 需要在主进程里用 clipboard.writeBuffer(format, buffer) 写自定义格式------这是 clipboard 模块的公开能力,不能砍。所以补丁把底层那个 WriteUnsafeRawData 恢复了,但只暴露给 C++ 侧,不重新打开网页可见的 API。
这个补丁插在两个 ProcessSingleton 补丁之间,正说明 .patches 的排序是按依赖 而不是按主题------只要不破坏依赖,随手插哪里都行。
这一点值得强调:**不要假设 patches/ 里相邻的补丁属于同一个功能。**顺序只表达依赖,不表达归类。
4.10.4 从这对补丁学到的
补丁是有向图,不是集合。
380 个补丁之间存在依赖关系,.patches 的线性顺序是这个图的一个拓扑排序。你可以删除一个补丁(只要没有别的补丁依赖它),但你不能随意重排。
这个性质带来一个实际后果:删除补丁比新增补丁危险。 新增只需要在末尾加一行;删除需要确认没有任何后续补丁依赖它的输出。
git-import-patches 的"reset 到 upstream-head 再重放"流程,本质上就是在重新计算这个拓扑排序------如果某个补丁被删了而后续补丁依赖它,重放会在那一步失败,你立刻知道。
4.11 Linux 沙箱:混合模式需要打开 zygote
support_mixed_sandbox_with_zygote.patch 的 commit message 值得完整引用:
On Linux, Chromium launches all new renderer processes via a "zygote" process which has the sandbox pre-initialized (see //docs/linux_zygote.md). In order to support mixed-sandbox mode, in which some renderers are launched with the sandbox engaged and others without it, we need the option to launch non-sandboxed renderers without going through the zygote.
Chromium already supports a
--no-zygoteflag, but it turns off the zygote completely, and thus also disables sandboxing. This patch allows the--no-zygoteflag to affect renderer processes on a case-by-case basis, checking immediately prior to launch whether to go through the zygote or not based on the command-line of the to-be-launched renderer.This patch could conceivably be upstreamed, as it does not affect production Chromium (which does not use the
--no-zygoteflag). However, the patch would need to be reviewed by the security team, as it does touch a security-sensitive class.
4.11.1 zygote 是什么,为什么是"全有或全无"
Linux 上的 zygote 是一个 fork server。它的工作流程是:
- browser 进程启动一个特殊的子进程(zygote)
- zygote 在启动时初始化沙箱(seccomp-bpf 过滤器、namespace 隔离)
- 需要新的渲染进程时,browser 通知 zygote
- zygote
fork()出一个子进程,子进程继承已经初始化好的沙箱 - 子进程 exec 或者直接跑渲染代码
这个设计的核心优势是沙箱初始化只做一次。seccomp 过滤器的安装、namespace 的创建都是相对昂贵的操作,而且必须在进程早期做(之后就没有权限了)。zygote 把这些成本摊到一次。
代价是:所有从 zygote fork 出来的进程共享同一套沙箱策略。 你不能让一个子进程有沙箱、另一个没有------因为它们在 fork 之前的状态是一样的。
--no-zygote 标志关掉整个 zygote,所有进程都不走 fork server,也就都没有沙箱。这是"全有或全无"。
4.11.2 Electron 为什么需要混合
Electron 的 webPreferences.sandbox: false 意味着渲染进程需要 Node 的完整能力:读文件、起子进程、加载原生模块。
这些能力和 seccomp 沙箱是根本冲突 的:seccomp 限制了 openat、execve、socket 等系统调用,而 Node 的核心功能就是调这些。
所以 Electron 必须支持"同一个应用里,有的窗口开沙箱、有的不开"。这在 Chromium 里是不存在的场景------Chrome 的所有渲染进程都开沙箱。
补丁的解法:把 --no-zygote 的语义从"关掉 zygote"改成"启动时逐个判断"。
cpp
// 伪代码,实际是 RenderProcessHostImpl 里的逻辑
bool should_use_zygote = !command_line.HasSwitch(switches::kNoZygote) ||
renderer_cmd_line.HasSwitch(switches::kNoZygote);
注意这里检查的是待启动渲染进程的命令行 ,不是 browser 进程的命令行。Electron 在构造渲染进程命令行时,根据 webPreferences.sandbox 决定要不要加 --no-zygote。
所以同一个 browser 进程可以:
- 为窗口 A 启动一个走 zygote 的沙箱渲染进程
- 为窗口 B 启动一个不走 zygote 的无沙箱渲染进程
4.11.3 为什么"可以上游化"但没上游化
commit message 自己说了:
This patch could conceivably be upstreamed, as it does not affect production Chromium (which does not use the
--no-zygoteflag). However, the patch would need to be reviewed by the security team, as it does touch a security-sensitive class.
这是三条理由里最微妙的一种情况:技术上可以上游,流程上不能。
"does not affect production Chromium"是对的------Chrome 从不传 --no-zygote,所以这个改动对 Chrome 是死代码。但"死代码"不等于"无害代码":它改的是 RenderProcessHostImpl 里决定进程启动路径的逻辑,这是一个安全敏感的类。
一个 bug 在这里可能导致:本该沙箱的进程走了无沙箱路径。这种 bug 在 Chrome 上是灾难性的。
所以 Chromium 的安全团队会要求非常严格的审查,而 Electron 团队没有动力去推动这个审查------因为补丁本身很小(不到 100 行),维护成本低于推动上游的成本。
这是一个典型的**"补丁经济学"决策**:不是"能不能上游",而是"上游的成本 vs 维护的成本"。
4.11.4 配套的 allow_new_privs
allow_new_privileges_in_unsandboxed_child_processes.patch 是和上面那个配套的:
cpp
// (For Electron), if we're launching without zygote, that means we're
// launching an unsandboxed process (since all sandboxed processes are
// forked from the zygote). Relax the allow_new_privs option to permit
// launching suid processes from unsandboxed child processes.
if (!base::CommandLine::ForCurrentProcess()->HasSwitch(switches::kNoZygote) &&
delegate_->GetZygote() == nullptr) {
options->allow_new_privs = true;
}
allow_new_privs 对应 Linux 的 PR_SET_NO_NEW_PRIVS 的反面 。默认情况下,Chromium 会给子进程设置 NO_NEW_PRIVS,意思是"这个进程及其后代不能通过 exec 获得新权限"(比如 exec 一个 setuid 程序)。
这个限制对沙箱进程是必须的------它是沙箱的一部分。但对 Electron 的无沙箱渲染进程,它是多余的:这个进程本来就有完整权限,为什么要禁止它 exec setuid 程序?
场景:一个 Electron 应用要调用 sudo、要启动一个需要特权的辅助程序、要用 pkexec。在无沙箱渲染进程里 child_process.spawn('sudo', ...),如果 NO_NEW_PRIVS 被设置,会失败并报 EPERM。
这个补丁的条件判断很有意思:
cpp
!HasSwitch(kNoZygote) && delegate_->GetZygote() == nullptr
两个条件都要满足。第一个是"命令行没有全局 --no-zygote"(否则整个进程树都是无沙箱的,另有一套处理)。第二个是"这个 delegate 拿不到 zygote"(说明这是个单独启动的、不走 zygote 的进程)。
这个双重判断的存在,说明作者在写的时候考虑过"全局 no-zygote"和"单进程 no-zygote"两种情况的区别。
4.12 Node 集成的六个关键补丁
patches/node/ 有 58 个补丁,但真正定义"Electron 里的 Node 是什么样"的,只有六个。其余 52 个是 V8 适配、perfetto、测试跳过。
4.12.1 build_add_gn_build_files.patch:不用 GYP
commit message 解释得很清楚:
This adds GN build files for Node, so we don't have to build with GYP.
Note that there always GN files in upstream Node in 20/21 branches, however those files were cherry-picked from main branch and do not really in 20/21. We have to wait until 22 is released to be able to build with upstream GN files.
这段话透露了一个现实:**Node 官方的 GN 构建文件在 20/21 分支上是不可用的。**它们是从 main 分支 cherry-pick 过去的,但 main 分支的目录结构、依赖声明、目标名都和 20/21 不同,所以那些文件在那个分支上跑不起来。
Chromium 的构建系统是 GN + Ninja。要把 Node 塞进去,必须让 Node 也能被 GN 描述。所以 Electron 自己写了一套 node.gni。
这个补丁的两个细节值得看:
细节一:node_path 的默认值问题。
node.gni - The node_* declare_args() are configured from Electron's build args (//electron/build/args/all.gn) rather than by changing their defaults here. The exception is node_path: node_module_version's default in the same declare_args() block runs exec_script() on $node_path, and GN evaluates it against node_path's default even when both are overridden, so gn gen fails unless the default points at the real checkout.
这是一个 GN 的陷阱,值得完整理解:
gn
declare_args() {
node_path = "..." # 必须指向真实 checkout
node_module_version = exec_script("...", [node_path]) # 默认值里用了 node_path
}
GN 在计算 declare_args() 的默认值时,不管你有没有在 args.gn 里覆盖 ,都会先算一遍表达式的值------因为要检查类型。所以 exec_script(..., [node_path]) 会用 node_path 的默认值 去执行,如果那个默认值指向一个不存在的目录,脚本失败,gn gen 直接崩溃。
即使你在 args.gn 里把两个都覆盖了,也没用------默认值计算发生在覆盖之前。
解法:把 node_path 的默认值设成真实路径(相对 //third_party/electron_node),让 exec_script 至少能跑通。
这个细节是"构建系统的坑"的完美样本:它不违反任何直觉,它只是不符合 GN 的求值顺序。
细节二:js2c 的参数改造。
补丁的末尾改了 node_js2c 这个 action 的参数:
diff
- args = [ rebase_path(node_js2c_path),
- rebase_path("$target_gen_dir/node_javascript.cc"),
- "--root", rebase_path("."),
- "lib", rebase_path("$target_gen_dir/config.gypi") ] +
- node_deps_files
+ args = [ rebase_path("$target_gen_dir/js2c_inputs"),
+ rebase_path(node_js2c_path),
+ rebase_path("$target_gen_dir/node_javascript.cc")] +
+ rebase_path(config_gypi) + node_deps_files +
+ original_fs_files + node_library_files
原来的 node_js2c 接口是"给我一个根目录和一堆 lib 路径,我自己去 glob"。改成了"给我一个 js2c_inputs 清单文件,我按清单读"。
为什么要改?因为 GN 需要精确知道 这个 action 的输入文件,才能做增量构建。如果 node_js2c 自己去 glob 目录,GN 就看不到那些文件,改了任何一个 JS 文件都不会触发重新生成。
这是 Chromium 构建的一条铁律:所有输入必须在 inputs 里显式声明,不能靠脚本自己去找。 违反这条规则的后果是"改了代码但构建没更新",或者"每次构建都重新生成"(如果声明了 inputs 但声明得太宽)。
original_fs_files 和 node_library_files 被显式加进 args,说明这两组文件现在是清单的一部分。original_fs 是 Electron 特有的------它是 Node 的 fs 模块的一个包装版本,让 fs 能正确处理 asar 归档。
4.12.2 pass_all_globals_through_require.patch:2016 年的一个设计
这个补丁非常老(2016-03-27),但它的机制至今没变。commit message 只有一行:
Pass all globals through "require"
diff 的核心是两处:
diff
+// Store the "global" variable from global scope into a local scope, so we can
+// still reference it from this file even after we deleted the "global" variable
+// from the global scope.
+const localGlobal = (typeof global !== 'undefined') ? global : undefined;
+// Do the same for "Buffer".
+const localBuffer = (typeof Buffer !== 'undefined') ? Buffer : undefined;
以及:
diff
- result = FunctionPrototypeCall(compiledWrapper, thisValue, exports, require, module, filename, dirname);
+ result = FunctionPrototypeCall(compiledWrapper, thisValue, exports,
+ require, module, filename, dirname,
+ process, localGlobal, localBuffer);
4.12.3 这两行在解决什么问题
正常 Node 里,一个模块被包裹在这样的函数里执行:
js
(function (exports, require, module, __filename, __dirname) {
// 你的模块代码
});
这六个参数就是 CommonJS 的全部"魔法"------它们不是全局变量,是函数参数。所以你在模块里写的 require,实际上是参数 require,不是全局的。
但 Node 里还有一批真正的全局变量 :global、process、Buffer、setTimeout、console......
Electron 的问题在于:渲染进程里,这些全局变量属于 Blink,不属于 Node。
具体说:渲染进程的 globalThis 是 window。window.Buffer 存在吗?在 nodeIntegration: true 的时候,Electron 会把 Node 的 Buffer 挂到 window 上。但那是 Electron 的 lib/preload 或者 lib/renderer 干的,不是 Node 的核心。
更麻烦的是"删掉全局变量"这件事。Electron 在渲染进程里会做一次 delete globalThis.process、delete globalThis.Buffer 之类的清理,原因是:**页面脚本不应该看到 Node 的全局对象。**如果页面的某个检测脚本 if (typeof process !== 'undefined') 发现 Node 存在,它可能走一条不同的代码路径(比如以为自己在打包器里)。
清理之后,Node 自己的模块加载器怎么办?lib/internal/modules/cjs/loader.js 里的代码要访问 process、global、Buffer------如果全局上没有了,就报 ReferenceError。
解法就是补丁里的 localGlobal / localBuffer:**在模块顶部,把全局变量抓到局部作用域里。**这样即使全局被删了,这个文件内部还能用。
然后在 Module.prototype._compile 里,把 process、localGlobal、localBuffer 作为额外的三个参数传给包裹函数:
js
(function (exports, require, module, __filename, __dirname, process, global, Buffer) {
// 模块代码
});
这样每个模块都能拿到这三个名字,即使全局被删了。
4.12.4 为什么这必须打补丁而不是在 Electron 里做
因为 _compile 函数在 Node 的 lib/internal/modules/cjs/loader.js 里,那是 Node 的核心代码,Node 把它编译进了二进制(通过 js2c)。
Electron 当然可以 fork 这个函数、复制一份到自己的代码里------但那样就失去了 Node 后续的所有修复。而且 _compile 被 require 的调用链深度依赖,复制一份意味着要同时复制调用它的所有代码。
所以只能打补丁。
这个补丁是"理由三"的典型:功能上根本对立。Node 假设全局变量存在,Electron 需要删掉全局变量,两者不能共存。
(顺带说:这个补丁只影响 CJS。ESM 的加载路径在 lib/internal/modules/esm/ 下,走的是另一套机制,见下一个补丁。)
4.12.5 ESM 三兄弟
patches/node/ 里有三个关于 ESM 的补丁,构成一条链路:
fix_expose_the_built-in_electron_module_via_the_esm_loader.patch:15-17:
This allows usage of
import { app } from 'electron'andimport('electron')natively in the browser + non-sandboxed renderer
改动很小,但位置很关键:
fix_expose_the_built-in_electron_module_via_the_esm_loader.patch:15-17
diff
if (!StringPrototypeStartsWith(url, 'node:') &&
+ !StringPrototypeStartsWith(url, 'electron:') &&
typeof result.source !== 'string' &&
这是 validateSourceStrict 里的检查。它原本的意思是:"node: 协议的 URL 允许没有 source(因为它们是内置模块),其它协议必须有 source。"
加上 electron: 之后,electron: 协议也被当作内置模块处理。
但这个改动只解决了"协议识别"。还有两个问题:
fix_do_not_resolve_electron_entrypoints.patch:
require('electron')from an ES module goes through the CJS pre-parser; skip reading a file named 'electron' from disk for it and expose the module's own keys as its named exports.
diff
+ if (filename === 'electron') {
+ return { module, exportNames: new SafeSet(['default', ...Object.keys(module.exports)]) };
+ }
问题:当你在 ESM 里写 import { app } from 'electron',Node 的 ESM 加载器会尝试做两件事之一:
- 如果
electron是内置模块,直接用 - 否则当作相对路径解析,去磁盘上找
electron.js/electron/index.js
第 1 条在打了上一个补丁后能走通,但"named exports 的静态分析"还是有问题。ESM 的 import { app } 需要知道 electron 模块有哪些具名导出,而 Node 的机制是"读模块源码,做 CJS 预解析"。
electron 模块没有源码文件------它是 Electron 在 lib/ 里用 module.exports = { app, BrowserWindow, ... } 动态构造的。预解析拿不到源码,就认为没有具名导出,import { app } 报错。
补丁直接短路:如果 filename 是 electron,就用 Object.keys(module.exports) 作为导出名集合。这是运行期反射,绕过了静态分析。
chore_expose_importmoduledynamically_and.patch:
This also subtly changes the behavior of shouldNotRegisterESMLoader to ensure that node sets up the handlers internally but simply avoids setting its own handlers on the Isolate. This is so that Electron can set it to its own blended handler between Node and Blink.
Not upstreamable.
这个补丁解决的是"谁来处理 import()"。
在一个 Electron 渲染进程里,import('./foo.js') 可能有三种语义:
- 如果
foo.js在文件系统上 → Node 的 ESM loader - 如果
foo.js是一个blob:URL → Blink 的模块加载器 - 如果是一个
http(s)://URL → Blink 的网络加载器
Node 的 shouldNotRegisterESMLoader 原本是一个硬开关:要么 Node 注册自己的 handler(全接管),要么不注册(Blink 全接管)。
Electron 需要"混合":先问 Blink,Blink 说"我不管这个"的时候再交给 Node。
所以补丁把这个开关的语义改了:**Node 内部照样设置好 handler,只是不往 Isolate 上装。**这样 Electron 可以自己装一个 handler,内部再分发给 Node 或 Blink。
commit message 里"Not upstreamable"这四个字用得很干脆。理由是:这个改动会让 Node 的行为在一个非 Electron 环境里变得不可预测------handler 设好了但没装,任何"我设了 handler 就应该被调用"的假设都失效。
4.12.6 第六个:js2c 的注入点,以及一个真实存在的优先级 bug
第六个关键补丁是 build_modify_js2c_py_to_allow_injection_of_original-fs_and_custom_embedder_js.patch。commit message:
This patch ensures that internal/fs/watchers can be loaded twice without JS errors and ensures embedder JS is loaded via LoadEmbedderJavaScriptSource.
That method is generated by our modifications to js2c.cc in the BUILD.gn patch
它解决两个问题,第二个是主线:
问题一:internal/fs/watchers 被加载两次。
Electron 有两份 fs:Node 原生的 fs,和 Electron 的 original-fs(绕过 asar 的那份)。两者都会 require('internal/fs/watchers'),于是那个模块被执行两次。
第二次执行时,ObjectDefineProperty(FSEvent.prototype, 'owner', {...}) 会对同一个属性重复定义。如果属性被定义为不可配置(configurable: false),第二次定义会抛 TypeError。
补丁的修法是把那行包进一个条件:
diff
+if (!'owner' in FSEvent.prototype) {
+ ObjectDefineProperty(FSEvent.prototype, 'owner', {
+ __proto__: null,
+ get() { return this[owner_symbol]; },
+ set(v) { return this[owner_symbol] = v; }
+ });
+}
注意这个 if 条件写错了。
JavaScript 的运算符优先级里,一元 ! 高于 in。所以 !'owner' in FSEvent.prototype 的求值顺序是:
js
(!'owner') in FSEvent.prototype
// 'owner' 是真值字符串 → !'owner' === false
// false in FSEvent.prototype
// 检查 FSEvent.prototype 是否有键名为 "false" 的属性
// → 没有 → 整个表达式是 false
// → !false ... 等等,前面已经取反了
准确说:(!'owner') in FSEvent.prototype 求值为 false(因为原型上没有名为 "false" 的键)。所以 if (false) ------括号里的代码永远不会执行。
作者显然想写 if (!('owner' in FSEvent.prototype))。
那为什么没有崩溃?因为 FSEvent.prototype 上的 owner 属性在 Node 里是可配置的(configurable: true),所以重复定义不报错。补丁想修的问题,在当前版本里其实不存在------那行代码是"防患于未然",而它因为写错而根本没生效。
这个 bug 从 2019 年存在至今。
我把它写进来,不是为了嘲笑谁,而是因为它精确地展示了补丁系统的一个真实风险:**补丁里的代码不像上游代码那样有大量 review 和测试覆盖。**一个 6 行的 if 判断,错了七年,没人发现,因为它守的是一个当前不会发生的场景。
如果你想验证,可以这样检查:
bash
node -e "const p={}; console.log(!'owner' in p)" # false
node -e "const p={}; console.log(!('owner' in p))" # true
两行输出不同,就是优先级问题。
问题二:LoadEmbedderJavaScriptSource。
这个是主线。补丁在 src/node_builtins.cc / .h 里加了一个方法:
diff
+ void LoadEmbedderJavaScriptSource(); // Loads embedder data into source_
node_builtins.cc 是 Node 的"内置 JS 源码注册表"------它持有一个 source_ map,从 builtin id 到 JS 源码。Node 用 js2c.py:11-14 在构建时把所有 lib/*.js 编译成 C++ 数组,塞进这个 map。
Electron 需要往这个 map 里再塞一份 自己的 JS(electron/js2c/* 那些 bundle)。原生的 js2c.py 只认 lib/ 目录,不认外部传入的 bundle。
所以补丁做了两件事:
- 改
js2c.py,让它接受额外的 bundle 列表 - 在
node_builtins.cc里加LoadEmbedderJavaScriptSource(),由 embedder 提供实现,在合适的时机把外部 bundle 注入source_
这就是第 3 章讲的七 bundle 体系在 Node 侧的落点。electron_js2c 那个 GN 目标产出的 C++ 数组,最终是通过这个注入点进到 Node 的 builtin 表里的。
这两个问题放在一个补丁里,是历史的偶然(同一个作者、同一次提交解决了两件事)。补丁系统的现实就是这样:补丁的边界由"谁在什么时候改了什么"决定,不由"逻辑上应该怎么分"决定。
4.13 代码缓存:三层补丁串起的一条链
这一节是本章技术密度最高的部分。它串起三个补丁:
feat_key_code_caches_to_the_patch_set_and_the_embedded_blob.patchelectron_build-time_v8_code_cache_for_the_electron_js2c_bundles.patchall.gn:26-29里的v8_version_hash_salt_files
以及第 3 章讲过的七 bundle / 五 flavor 体系。
4.13.1 背景:为什么 Electron 特别需要代码缓存
第 3 章算过:Electron 的 electron/js2c/* 有七个 bundle,全量源码在几 MB 量级。这些代码在每个进程启动时都要被 V8 解析、编译。
- browser 进程:1 次
- 每个渲染进程:1 次
- 每个 utility 进程:1 次
- 每个 worker:1 次
一个典型应用开 5 个窗口 + 3 个 utility 进程,就是 8 次全量编译。如果每次 30ms,就是 240ms 的纯启动开销------而且是每个进程各自付,不能共享。
V8 的代码缓存(code cache)机制正是为此设计的:把编译结果序列化成字节码 + 反馈向量,下次直接反序列化,跳过解析和编译。
但在 Electron 里用代码缓存有三个障碍:
- 缓存在哪? 写磁盘需要路径、需要权限、需要处理失效。渲染进程可能是沙箱的,不能写文件。
- 什么时候生成? 应用第一次启动时没有缓存,第二次才有------第一次启动永远慢。
- 缓存什么时候失效? V8 用
Version::Hash()做 key,而这个 hash 只包含major.minor.build.patch。
第三个障碍是致命的,我们看补丁怎么描述它。
4.13.2 V8 层:给版本 hash 加盐
feat_key_code_caches_to_the_patch_set_and_the_embedded_blob.patch 的 commit message:
V8 keys serialized code (JS code caches and serialized wasm modules) on
Version::Hash(), i.e. on major.minor.build.patch only. Electron applies V8 fixes on release branches as patches without changing that version number, so a build with a different patch set still accepts serialized code written by the previous release. Serialized wasm code stores calls to builtins as raw Builtin ids, so when a patch adds, removes or reorders a builtin the deserialized code is relinked against the wrong builtins and crashes or traps at runtime.
这段话说了一个非常具体的崩溃场景:
第 1 步 :Electron 45.2.3 用的是 V8 15.x.y.z(假设)。这个版本的 Version::Hash() 是某个值 H。
第 2 步 :用户在 45.2.3 上跑了一次应用,V8 把编译好的 wasm 模块序列化到磁盘,key 是 H。序列化的内容里包含对 builtin 的调用,编码方式是原始 Builtin id------比如"调用第 47 号 builtin"。
第 3 步 :Electron 发布 45.2.4,仍然用 V8 15.x.y.z(没升版本号),但从 patches/v8/ 里 cherry-pick 了一个修复,这个修复在 builtin 列表里插入了一个新 builtin。
第 4 步 :用户的 45.2.4 启动,读到磁盘上 key 为 H 的 wasm 缓存------hash 一样,所以认为缓存有效,直接反序列化。
第 5 步 :反序列化出来的代码里,"调用第 47 号 builtin"现在指向了另一个函数(因为插入了新的,后面全部后移)。于是运行时跳到错误的代码,崩溃或者 trap。
这个 bug 的恶劣之处:它只在"升级过、且升级前跑过 wasm"的用户身上出现,且表现为随机的内存错误。这种 bug 极难定位。
修复方案是两层:
第一层:给 Version::Hash() 加盐。
all.gn:25-29
gn
# build/args/all.gn
_v8_patch_dir = "//electron/patches/v8"
v8_version_hash_salt_files = [ "$_v8_patch_dir/.patches" ]
foreach(_patch, read_file("$_v8_patch_dir/.patches", "list lines")) {
if (_patch != "") {
v8_version_hash_salt_files += [ "$_v8_patch_dir/$_patch" ]
}
}
GN 在 gn gen 时读 patches/v8/.patches 的每一行,把清单本身和每个补丁文件都加进 v8_version_hash_salt_files。
然后 V8 的 GN 会计算这些文件的内容哈希,写进一个头文件(commit message 说 "The salt reaches version.cc through a header written at gn gen time so that changing it recompiles one file.")。
于是 Version::Hash() 变成 hash(major, minor, build, patch, salt)。
关键效果:只要 patches/v8/ 里任何一个文件变了,salt 就变,所有代码缓存和序列化 wasm 全部失效。
这个设计的精妙之处在于"gn gen 时计算":
- 如果运行时计算,那每次启动都要算一遍所有补丁文件的哈希------启动开销不可接受
- 如果手动维护一个版本号,人会忘
- GN 时计算,且只影响一个头文件,改了 salt 只重编
version.cc这一个文件,增量构建成本极低
注意 foreach 里用的是 read_file(..., "list lines")。第 3 章我在这上面犯过错,写成了 "list words"。这个细节在这里特别重要:"list lines" 按行分割,每个补丁文件名是一行;"list words" 会按空白分割,文件名里的下划线不受影响但如果有空格就会错,而且行为不同。源码是 "list lines"。
第二层:把 embedded blob 的 hash 加进 wasm 模块头。
commit message 的后半句:
the embedded blob's data and code hashes in the serialized wasm module header, so that a module is only ever deserialized into a build whose blob matches
V8 有一个"embedded builtin blob"------把所有 builtin 的机器码打包成一段连续的只读内存,这样 builtin 调用可以用相对偏移而不是 GOT 查找,速度快很多。
这段 blob 在不同构建里内容不同(因为编译器和优化级别不同)。如果 wasm 缓存里存的是对 blob 内偏移的引用,而新构建的 blob 布局变了,偏移就指向错误的位置。
所以这个补丁把 blob 的 data hash 和 code hash 写进 wasm 模块的序列化头。反序列化前先比对:不匹配就拒绝,退回重新编译。
对比一下两层的粒度:
- 盐:粒度粗,任何补丁变动都失效。保守但安全。
- blob hash:粒度细,只在实际二进制布局变化时失效。
两层叠加的效果是:盐保证了"跨版本不会误用",blob hash 保证了"同版本不同构建也不会误用"。
4.13.3 Node 层:消费侧的钩子
electron_build-time_v8_code_cache_for_the_electron_js2c_bundles.patch 的 commit message 长得像设计文档:
Makes the embedded electron/js2c/* framework bundles -- and, in processes whose Node.js environment is bootstrapped from scratch rather than deserialized from the embedded Node startup snapshot, Node's own builtins -- consumable from a build-time-generated V8 code cache so each process deserializes them (
kConsumeCodeCache) instead of parsing and compiling from source at startup.
这段话里有三个关键词需要拆:
"build-time-generated" :缓存在构建时生成,不在运行时。这解决了 4.13.1 说的障碍 2(第一次启动慢)------所有用户第一次启动就已经有缓存了。
"embedded" :缓存被打包进二进制,跟着 electron 可执行文件一起分发。这解决了障碍 1(缓存在哪)------不需要写磁盘,不需要权限,沙箱进程也能用。
"kConsumeCodeCache":V8 的编译选项,表示"尝试从 code cache 反序列化,而不是从源码编译"。
补丁做了三件事:
第一件:让 4 参数的 LookupAndCompileFunction 支持 code cache。
The caller-parameterized 4-arg BuiltinLoader::LookupAndCompileFunction (used by
electron::util::CompileAndCallfor the framework bundles) previously always compiled from source; honor a RefreshCodeCache-supplied cache via kConsumeCodeCache, keyed by id, with graceful compile-from-source fallback.
node::BuiltinLoader 有两个 LookupAndCompileFunction 重载:
- 3 参数版:给 Node 自己的 builtin 用,已经支持 code cache
- 4 参数版:多一个 caller 参数,给"外部调用者"用,不支持 code cache
Electron 的 electron::util::CompileAndCall 用的是 4 参数版(因为它要传自己的 context 和 realm)。所以补丁要让 4 参数版也支持。
"keyed by id" 是个细节:缓存用 builtin 的 id 做 key,而不是文件名。因为同一个文件可能被不同 flavor 编译成不同的 bundle。
"graceful compile-from-source fallback" 也是必须的:如果缓存损坏、如果 salt 不匹配、如果 V8 版本变了------任何情况下都要能退回编译,而不是崩溃。
第二件:SetProcessDefaultCodeCache。
BuiltinLoader::SetProcessDefaultCodeCache(): a process-wide cache every BuiltinLoader constructed afterwards is seeded with. The bootstrap (per-context scripts in NewContext, internal/bootstrap/* inside CreateEnvironment) runs before an embedder can reach env->builtin_loader(), so RefreshCodeCache alone cannot cover it; a snapshot's cache and later RefreshCodeCache calls still merge over the default.
这段话说的是一个时序问题:
Node 启动时会跑一批 bootstrap 脚本(internal/bootstrap/*)。这些脚本在 CreateEnvironment 里执行,而 CreateEnvironment 返回之前,调用者拿不到 env->builtin_loader()。
所以如果你只有 RefreshCodeCache(在拿到 env 之后调用),那 bootstrap 阶段就已经错过了------它已经用源码编译完了。
解法是加一个进程级默认值 :在任何 BuiltinLoader 构造之前,先 SetProcessDefaultCodeCache(...)。之后每个新建的 BuiltinLoader 都自动带上这个缓存。
这个"进程级默认 + 实例级覆盖"的模式,在 Chromium 里很常见(比如 base::FeatureList 的默认值)。它的好处是不需要改动 bootstrap 流程------bootstrap 不知道自己用了缓存,它只是碰巧用上了。
第三件:一个测试专用的记录器。
Test-only (DCHECK builds, compiled out of production): a process-global record of which builtins' code cache was accepted, exposed to the api-js2c-code-cache spec via the electron v8_util binding.
这是我在本仓库里见到的最值得学习的测试设计。
问题:怎么测试"代码缓存确实被用了"?
如果只测"应用启动成功",那缓存没生效也测不出来------只是慢一点。如果测启动时间,那测试不稳定。
Electron 的做法是:在 DCHECK 构建里,记录每个 builtin 的缓存是否被接受,通过 v8_util 绑定暴露给测试。测试直接断言"这 7 个 bundle 的缓存都被接受了"。
这个设计的三个要点:
- 编译期排除 :
compiled out of production,生产构建里这段代码不存在,零开销 - 可观测:不是"间接推断",是"直接查询"
- 绑定到测试 :通过
v8_util这个已有绑定暴露,不需要新增 IPC
第 21 章讲测试体系时会再提到这个模式。
4.13.4 三层如何咬合
现在把三层放一起看:
bash
构建时:
patches/v8/* ──hash──> salt ──> 写进 version.h ──> Version::Hash()
│
└──> 生成 code cache ──> 嵌入二进制 ──> (keyed by builtin id)
运行时(每个进程):
process 启动
→ SetProcessDefaultCodeCache(embedded_cache)
→ BuiltinLoader 构造(自动带缓存)
→ bootstrap 脚本用 kConsumeCodeCache 反序列化
→ 反序列化失败?→ 退回编译(不报错)
→ 反序列化成功 → 记录到 DCHECK 记录器(仅测试构建)
这个链条的每一环都依赖前一环:
- 没有 V8 的盐,Node 侧的缓存会在版本升级后误用,崩溃
- 没有 Node 侧的三个钩子,V8 的盐只是让缓存失效,没有缓存可用
- 没有构建时的生成,运行时就没有东西可消费
而这三层分散在三个不同的补丁集里(v8、node、chromium 的 BUILD.gn),由 all.gn 里的几个变量串起来。
这就是为什么第 3 章要先讲构建系统。 补丁不是孤立的 diff,它们通过构建参数互相看见。
4.14 打印栈:一个 991 行的"去 Chrome 化"样本
printing.patch 的 commit message 是本章里唯一一个按文件列出改动理由的:
Adapts Chromium's printing stack to Electron, which has no Profile or PrefService, does not surface Chromium's print error dialogs, does not use enterprise content analysis, and needs to support programmatic and silent printing driven by
webContents.print(options). The changes originate from github.com/electron/el... and github.com/electron/el... and have been extended since.Per-file summary:
chrome/browser/printing/print_job.cc Electron has no PrefService, so the helpers that read prefs off a WebContents are compiled out and their callers pass nullptr. On Windows use_skia_ is forced to false since there is no managed pref to consult.
chrome/browser/printing/print_view_manager_base.cc / .h ...
这个"per-file summary"的写法值得单独表扬。991 行、13 个文件的补丁,如果只写一句"适配打印栈",后来者根本不知道每一处改动的理由。而按文件写,reviewer 可以逐个核对。
4.14.1 四个"没有"
commit message 的第一段列了四个"Electron 没有":
- 没有
Profile------ Chromium 的打印栈深度依赖Profile(通过Profile::GetPrefs()拿打印设置、通过ProfileIOData拿打印机列表)。 - 没有
PrefService------ 打印设置(纸张大小、边距、是否双面)都存在PrefService里。 - 不显示 Chromium 的错误对话框 ------ Chromium 在打印失败时会弹一个原生对话框,Electron 要把错误通过 Promise 返回给 JS。
- 不用企业内容分析 ------ Chromium 企业版有个 DLP(数据防泄漏)功能,打印前会扫描内容。Electron 没有这个需求。
这四个"没有"对应四种改动模式:
模式一:编译掉(#if 0 或直接删除)。 依赖 PrefService 的辅助函数整个消失,调用点传 nullptr。
cpp
// print_job.cc 里原本是
bool use_skia = prefs->GetBoolean(prefs::kPrintUsingSkia);
// 改成
bool use_skia = false; // Electron 没有 managed pref
模式二:替换实现。 错误对话框改成回调。
模式三:短路。 企业内容分析直接返回"通过"。
模式四:新增 API 路径。 webContents.print(options) 需要一条"不弹对话框、直接打"的路径。Chromium 的打印流程是"弹预览 → 用户点打印 → 开始打印",Electron 需要跳过前两步。
4.14.2 为什么打印栈必须打补丁
有人会问:Chromium 的打印不是在 components/printing/ 里吗?Electron 不能直接用吗?
能,但用不了。
components/printing/ 是一个"半成品"库------它定义了打印的抽象(PrintViewManager、PrintJob、PdfMetafile),但把"谁来提供 PrintPreview 的 UI"、"谁来读打印设置"、"谁来显示错误"这些问题留给了 embedder。
Chrome 的实现在 chrome/browser/printing/,那是 Chrome 特有的。Electron 要用 components/printing/,就必须自己实现那些 embedder 的职责------而 components/printing/ 的接口设计是围绕 Chrome 的假设来的,很多地方直接 static_cast 到 Chrome 的类型。
所以补丁改的是 chrome/browser/printing/ 下的文件------**Electron 直接在 Chrome 的实现上改,而不是用 components 层重写一套。**这是一个务实的选择:改 991 行 vs 重写几千行。
代价是:**每次 Chromium 改打印栈,这个补丁都要跟着改。**而 Chromium 的打印栈改动频繁(因为 Chrome 自己在做 Print Preview 的重构)。
printing.patch 是 patches/chromium/ 里维护成本最高的补丁之一。它也是"补丁债"的典型样本:991 行、13 个文件、跨多个 Chromium 组件,每次升级都要重新审视。
4.14.3 一个可迁移的判断标准
从 printing.patch 可以提炼出一个通用判断:什么时候该打补丁,什么时候该复制代码?
| 情况 | 选择 | 理由 |
|---|---|---|
| 上游代码是"半成品库",接口稳定 | 打补丁 | 复制会失去上游修复 |
| 上游代码是"完整实现",但耦合 Chrome 类型 | 复制 + 裁剪 | 补丁会无穷增长 |
| 上游代码改动频繁 | 复制 | 补丁维护成本 > 复制成本 |
| 上游代码几乎不动 | 打补丁 | 补丁成本低 |
printing.patch 落在第一格和第三格之间------它是半成品库(第一格),但改动频繁(第三格)。所以它成了本章统计里行数第 7 大的补丁,也是抱怨最多的补丁之一。
4.15 MAS:2538 行、68 个文件、22 个私有 API
mas_avoid_private_macos_api_usage.patch.patch:6-13 是行数第二、文件数第一的补丁:2,538 行、68 个文件。
(文件名两个 .patch 后缀是历史遗留。改名的代价是破坏 .patches 清单和所有引用它的脚本,所以留着。)
4.15.1 commit message 里的 22 个符号
markdown
* Use the stub killring file when building blink
* Remove usage of the following private APIs
* abort_report_np
* pthread_fchdir_np
* pthread_chdir_np
* SetApplicationIsDaemon
* _LSSetApplicationLaunchServicesServerConnectionStatus
* AreDeviceAndUserJoinedToDomain
* _CFIsObjC
* CGSSetWindowCaptureExcludeShape
* CGRegionCreateWithRect
* CTFontCopyVariationAxesInternal
* AudioDeviceDuck
* NSNextStepFrame
* NSThemeFrame
* NSTextInputReplacementRangeAttributeName
* _toolbarView
* _menuImpl
* _removeFromGroups:
* _isConsideredOpenForPersistentState
* _boundsIfOpen
* _resizeDirectionForMouseLocation:
* NSAppendToKillRing
* kCFBundleNumericVersionKey
* __NSNewKillRingSequence
这份清单本身就是一份"Chromium 用了哪些 macOS 私有 API"的考古报告。
逐类看:
C 运行时 / 私有 libc :abort_report_np(崩溃时打印报告)、pthread_fchdir_np / pthread_chdir_np(线程级 chdir,POSIX 里没有,是 Apple 的私有扩展)。
Launch Services :_LSSetApplicationLaunchServicesServerConnectionStatus(控制是否连接 LS 服务器)。带下划线前缀 = 私有。
Core Foundation :_CFIsObjC(判断一个 CFType 是否是 ObjC 对象)、kCFBundleNumericVersionKey(bundle 的数字版本号,私有 key)。
Core Graphics :CGSSetWindowCaptureExcludeShape(设置窗口捕获排除区域,用于屏幕共享时排除自己的窗口)、CGRegionCreateWithRect(创建一个矩形 region)。
Core Text :CTFontCopyVariationAxesInternal(字体可变轴,私有版本)。
Core Audio :AudioDeviceDuck(把音频音量压低,用于语音助手场景)。
AppKit 私有类/方法 :NSNextStepFrame、NSThemeFrame(窗口边框的私有类)、NSTextInputReplacementRangeAttributeName(输入法替换范围的私有属性名)、_toolbarView、_menuImpl、_removeFromGroups:、_isConsideredOpenForPersistentState、_boundsIfOpen、_resizeDirectionForMouseLocation:。
Kill Ring :NSAppendToKillRing、__NSNewKillRingSequence------这是 Emacs 风格的"kill ring"(剪切板历史)。Blink 在 macOS 上实现编辑命令时用了它。
4.15.2 为什么这些 API 必须消失
App Store 审核会扫描二进制的符号表。如果发现对私有 API 的引用(哪怕只是字符串),直接拒绝。
注意"引用"的判定方式:审核工具不看你有没有调用,只看符号表里有没有那个字符串。所以即使那个代码路径永远不会执行(比如只在非 MAS 构建里跑),只要是同一个二进制,就会被拒。
这就是为什么必须编译期排除,而不是运行期判断。
is_mas_build 这个 GN 变量就是为此存在的。MAS 构建时 is_mas_build = true,所有相关代码用 #if !IS_MAS_BUILD() 包起来,整个消失。
4.15.3 从 GN 变量到 C++ 宏:一条手工搭的桥
这里有一个容易被忽略但很能说明 Electron 构建风格的细节:is_mas_build(GN 变量)和 IS_MAS_BUILD()(C++ 宏)之间没有自动转换。
Chromium 自己的做法是用 buildflag_header------一个 GN 模板,自动把 declare_args() 里的变量生成成 #define BUILDFLAG(IS_XXX) ... 形式,并且保证改了变量只重编依赖它的文件。
Electron 的这个 IS_MAS_BUILD 不是 buildflag。它是一个 Python 脚本生成的裸头文件。
build/config/BUILD.gn:1-8 的全文只有 12 行:
gn
action("generate_mas_config") {
outputs = [ "$target_gen_dir/../../mas.h" ]
script = "../../script/generate-mas-config.py"
if (is_mas_build) {
args = [ "true" ]
} else {
args = [ "false" ]
}
args += rebase_path(outputs)
}
script/generate-mas-config.py:1-8 的全文:
python
import sys
def main(is_mas_build, out_file):
is_mas_num = 0
if is_mas_build:
is_mas_num = 1
with open(out_file, 'w', encoding="utf-8") as f:
content = ''
content += '#ifndef ELECTRON_GEN_MAS_BUILD_H_\n'
content += '#define ELECTRON_GEN_MAS_BUILD_H_\n'
content += '#define IS_MAS_BUILD() ' + str(is_mas_num) + '\n'
content += '#endif\n'
f.write(content)
生成出来的 mas.h 是:
c
#ifndef ELECTRON_GEN_MAS_BUILD_H_
#define ELECTRON_GEN_MAS_BUILD_H_
#define IS_MAS_BUILD() 1 // 或 0
#endif
注意 #define IS_MAS_BUILD() 1 里的那对空括号------这是一个函数式宏,不是一个常量宏。
这不是笔误。我数了 mas_avoid_private_macos_api_usage.patch.patch 里全部 130 处引用,全部写成 IS_MAS_BUILD(),带括号,一处例外都没有。
为什么用函数式宏?这其实是一个刻意的选择,有三个好处:
**好处一:它不会和变量名冲突。**如果写成 #define IS_MAS_BUILD 1,那么任何叫 IS_MAS_BUILD 的标识符(局部变量、参数、成员)都会被替换。函数式宏只替换后面紧跟 ( 的形式,误伤面小得多。
好处二:它可以用在 #if 里,也可以用在表达式里。 IS_MAS_BUILD() 在预处理阶段展开成 1 或 0,所以 #if IS_MAS_BUILD() 和 int x = IS_MAS_BUILD(); 都合法。这一点对 C++ 代码里做常量折叠有用。
**好处三:它明确标记了"这不是 buildflag"。**Chromium 的 BUILDFLAG(IS_MAS_BUILD) 是一个模板生成的、有严格类型和文件依赖追踪的机制。Electron 用一个长得像但不一样的东西,是在视觉上区分"这是 Electron 自己的开关"。
代价也很明显:
代价一:没有增量构建的精细追踪。 buildflag_header 生成的头部包含一个 #define 和一个 hash,改了变量只重编依赖它的文件。而 mas.h 是一个普通输出文件,改了它,所有 include 它的文件全部重编------在 MAS 构建里这意味着大量重编。
不过这个代价在实践中不重要,因为 is_mas_build 在一个构建里是常量,不会中途改。
代价二:#define 会污染命名空间。 mas.h 被 include 到哪里,IS_MAS_BUILD 就在哪个翻译单元里存在。如果某个第三方头文件也定义了同名宏(不太可能,但不为零),就会冲突。
**代价三:没有 BUILDFLAG 的 IDE 支持。**Chromium 的 BUILDFLAG 在 clangd / VS Code 里有特殊处理,能让代码在 #if BUILDFLAG(X) 为假时灰掉。自定义宏没有这个待遇。
这个细节之所以值得写进书里,是因为它示范了 Electron 构建系统的一个通用模式:当 Chromium 的机制(buildflag_header)用起来麻烦时,用最简单的办法(Python 生成头文件)绕过去,然后接受它的不完美。
同一个模式在别处也出现过------第 3 章讲的 electron_vendor_version 三标志编码,也是"不追求优雅,只追求能跑且可验证"。
4.15.4 替换策略:四类
第一类:功能可以降级。
AudioDeviceDuck 是"压低其他应用的音量"。MAS 版本直接不做------用户的音量不受影响,功能降级但可用。
第二类:有公开替代。
_CFIsObjC 可以用 CFGetTypeID + 类型比较替代。kCFBundleNumericVersionKey 可以用 CFBundleGetVersionNumber(公开 API)。
第三类:自己实现。
pthread_fchdir_np 的用途是"让某个线程用不同的工作目录"。公开 API 里没有线程级的 chdir,但可以自己维护一个线程局部变量 + 所有文件操作都走一个封装层。Chromium 的 base::FilePath 体系恰好有这层封装,所以改成"在封装层做路径解析"。
第四类:删掉功能。
CGSSetWindowCaptureExcludeShape(屏幕共享时排除自己窗口)------MAS 版本不支持这个。这是一个真实的功能损失:MAS 版的 Electron 应用做屏幕共享时,无法自动排除自己的窗口。
4.15.5 killring stub
commit message 的第一条"Use the stub killring file when building blink"值得单独说。
Blink 在 macOS 上有一个 KillRingMac.mm,它调用 NSAppendToKillRing 等私有函数来实现 Emacs 风格的编辑命令(Ctrl+K 杀到行尾、Ctrl+Y 粘回来)。
MAS 构建需要一个 stub------一个实现了同样接口但什么都不做的版本。于是仓库里有两份实现,GN 根据 is_mas_build 选一份编译。
这个技巧的通用形式是:**把"用了私有 API 的模块"整个替换成 stub,而不是逐个 patch 掉调用点。**前者是 1 个文件的选择,后者是 N 个调用点的修改。
代价是:stub 版本功能缺失。MAS 版的 Electron 里,Ctrl+K / Ctrl+Y 不工作。
4.16 OSR:把 viz 的门打开
feat_enable_offscreen_rendering_with_viz_compositor.patch:58-62,729 行、25 个文件。commit message:
This patch adds hooks in the relevant places that allow for off-screen rendering with the viz compositor by way of a custom HostDisplayClient and LayeredWindowUpdater.
4.16.1 一个 #if 的移动
补丁里最能说明问题的,是这个 diff:
diff
-#if BUILDFLAG(IS_WIN)
void HostDisplayClient::CreateLayeredWindowUpdater(
mojo::PendingReceiver<mojom::LayeredWindowUpdater> receiver) {
+#if BUILDFLAG(IS_WIN)
if (!NeedsToUseLayerWindow(widget_)) {
改动内容 :把一个 #if BUILDFLAG(IS_WIN) 从函数外面挪到了函数里面。
效果:这个函数在所有平台都存在了,但 Windows 特有的逻辑仍然只在 Windows 上编译。
为什么需要 :LayeredWindowUpdater 是"分层窗口"的上传器------它接收合成后的帧,交给窗口系统。Windows 用 UpdateLayeredWindow,其它平台原本不需要这个接口。
Electron 的 OSR 需要它------因为 OSR 的帧也是通过 LayeredWindowUpdater 这条路径拿到的。Electron 实现了一个 OffScreenLayeredWindowUpdater,在 macOS / Linux 上也要用这个接口。
所以补丁做的事是:把这个接口从"Windows 专有"提升为"跨平台可用,但默认实现只在 Windows 上有意义"。
这种改动的维护成本极低,因为 #if 的内容没变,只是位置变了。但它是"扩展点不够用"的完美例子:接口存在,但被平台宏锁住了。
4.16.2 25 个文件都改了什么
729 行分布在 25 个文件,平均每个 29 行。这个分布说明它不是"一个大改动",而是"25 个小口子"。
典型的口子:
HostDisplayClient的构造参数加一个标志LayeredWindowUpdater的接口加一个方法(SetFrameRate)viz::Display的创建路径加一个分支RootCompositorFrameSinkImpl允许自定义HostDisplayClientSkiaOutputSurfaceImpl允许拿到SharedImage而不提交给窗口
每一个都是"如果不打这个洞,OSR 就走不通"。
4.16.3 OSR 是 Electron 最脆的功能
这里给一个预警:OSR 在 Electron 里长期处于"能跑但经常坏"的状态。
原因在架构层面:
- OSR 依赖 viz 合成的内部接口(
HostDisplayClient、LayeredWindowUpdater、SharedImage) - 这些接口是 Chromium 为"把帧送到窗口"设计的,不是为"把帧送到内存"设计的
- Chromium 每次重构合成管线,这些接口都会变
- Electron 的 OSR 补丁必须跟着变
所以 feat_enable_offscreen_rendering_with_viz_compositor.patch 的维护频率很高。你如果要用 OSR 做产品(比如无头截图服务、远程浏览器),要做好"每次升 Electron 都要测 OSR"的准备。
第 19 章会详细讲 OSR 的实现和使用。
4.17 oscrypt:6504 行的回退与一个"同步 API"的命运
revert_oscrypt_remove_sync_backend.patch 是本章统计里最大的补丁:6,504 行、41 个文件、6,232 行新增、0 行删除。
零删除、六千新增------这个形态说明它恢复了一整套被删掉的代码。
4.17.1 commit message
vbnet
chore: revert [OSCrypt] Remove sync backend
Electron still depends on the synchronous os_crypt API.
Revert upstream CL 7765593 until migration to async is complete.
Patch is modified to remove usage of "crypto/hkdf.h" on Windows for mock keys
as it was removed upstream.
Ref: https://chromium-review.googlesource.com/c/chromium/src/+/7765593
Ref: https://chromium-review.googlesource.com/c/chromium/src/+/8072603
三句话,三个信息:
- Electron 还在用同步的 os_crypt API。
- 上游 CL 7765593 删掉了同步后端,所以要回退它。
- 回退时还做了适配 :上游在删同步后端的同时也删了
crypto/hkdf.h,而回退的代码依赖它,所以 mock key 的部分改成不用 hkdf。
第 3 点特别重要:**这不是一个纯粹的 revert。**纯 revert 会编译失败,因为被删的依赖(hkdf.h)回不来了。所以补丁作者在恢复代码的同时,改写了依赖 hkdf 的那几处。
这解释了"6,232 行新增、0 行删除"------新增的是被删掉的文件的完整内容(new file mode),零删除是因为那些文件在当前树里已经不存在了,没有东西可删。
4.17.2 os_crypt 是什么,为什么 Electron 需要同步版
os_crypt 是 Chromium 的"用操作系统密钥保护数据"的抽象层:
- Windows :DPAPI(
CryptProtectData/CryptUnprotectData) - macOS :Keychain(
SecItemAdd/SecItemCopyMatching) - Linux:libsecret(Secret Service API)或 kwallet
它的典型用途是加密 Cookie、密码、支付信息。加密的密钥来自操作系统,绑定到当前用户账户------别的用户拿到文件也解不开。
Chromium 原本有同步和异步两套 API:
OSCrypt::EncryptString(plaintext, &ciphertext)------ 同步OSCryptAsync::EncryptString(...)------ 异步,返回 callback
上游删除同步版的原因很合理:**在 UI 线程上做同步的 Keychain 访问会卡界面。**macOS 的 SecItemCopyMatching 可能因为 Keychain 被锁而阻塞几秒,那几秒里整个浏览器 UI 冻住。
所以上游推动所有调用者迁移到异步,然后删掉同步实现。
4.17.3 Electron 为什么迁不动
Electron 的 safeStorage API 是同步的:
js
const { safeStorage } = require('electron')
const encrypted = safeStorage.encryptString('secret') // 同步返回 Buffer
const decrypted = safeStorage.decryptString(encrypted) // 同步返回 string
这是已发布的公开 API 。改成异步是破坏性变更------所有调用 safeStorage.encryptString() 的代码都要改成 await。
Electron 的破坏性变更策略是:大版本才能改,而且要有充分的迁移期。safeStorage 的异步化在 Electron 的 roadmap 上,但没完成。
所以只能用 6,504 行的补丁把同步后端留住。
4.17.4 这个补丁的三个风险
风险一:它是"复活死代码"。
被恢复的代码在上游已经不再维护了。如果那段代码里有安全漏洞,上游不会修(因为它已经不存在了),Electron 要自己修。
风险二:它阻塞了未来。
每次上游改 os_crypt 的其它部分(比如加一个新的平台后端),这个 6,504 行的补丁都可能冲突。而且冲突的解决需要理解"哪些是新代码需要的,哪些是同步后端专有的"。
风险三:它依赖已删除的头文件。
补丁自己说了:crypto/hkdf.h 被删了,所以 mock key 部分要改写。这意味着任何依赖 hkdf 的新代码都会坏------如果 Electron 别的地方要用 hkdf,就得自己恢复那个头文件。
4.17.5 从这个补丁学到的
这是本章最重要的教训之一:
公开 API 的同步/异步选择,是一个会锁定你十年的决策。
safeStorage.encryptString 在 2018 年左右加入 Electron。当时同步是最自然的选择------底层 API 是同步的,包装一层就完事。没人会想到,八年后这个选择会逼着团队维护一个 6,504 行的补丁,去对抗上游的删除。
这个教训对所有框架设计者都成立:当你包装一个底层 API 时,如果你的包装是同步的,你就永远无法在下层改成异步。
反过来,如果你的包装一开始就是异步的(哪怕底层是同步的),你保留了未来。代价是调用方要多写 await。
Electron 在别的 API 上做了相反的选择------dialog.showOpenDialog 早期是同步返回的(showOpenDialogSync),后来加了 Promise 版本,两个并存。这是"双 API"策略,比"单同步"好,因为新代码可以用异步。
safeStorage 没有走这条路。
4.18 cherry-pick:127 个补丁的来历与风险
本章统计里,cherry-pick-* 有 127 个,占全部补丁的三分之一。这一节专门讲这一类。
4.18.1 命名即文档
bash
chromium/cherry-pick-9c58c948fa47.patch
chromium/cherry-pick-63c916f7b401.patch
chromium/cherry-pick-41e502b417de.patch
chromium/cherry-pick-7b99dd29ba26.patch
chromium/cherry-pick-e7e3e86be6cc.patch
chromium/cherry-pick-8fcd65696297.patch
chromium/cherry-pick-431b8be6a111.patch
chromium/cherry-pick-403a04e98d9f.patch
chromium/cherry-pick-ad4492e24c08.patch
chromium/cherry-pick-2bbd07a96ab2.patch
每个文件名就是上游 commit 的 sha 前 12 位。这是可验证的:在 Chromium 仓库里 git show 9c58c948fa47,能看到原始 commit。
这个规范的价值:
- 可追溯:这个补丁从哪来,一目了然
- 可判定死亡:上游合了就能删
- 可理解意图 :
git show能读到原始 commit message,那里的解释通常比补丁文件里的更详细
4.18.2 为什么需要 127 个
Chromium 的发版流程是:
- main 分支持续开发
- 每 6 周从 main 切一个 release 分支(比如
M156) - release 分支上只接受"关键修复"(安全、崩溃、严重功能问题)
- 这些修复先合到 main,再用
Merge-Request流程合到 release
Electron 45.2.3 基于 Chromium 156.0.8078.80,它跟的是 M156 分支。
如果 Electron 需要某个修复,而那个修复:
- 已经在 main 上
- 还没 merge 到 M156
- 或者 merge 被拒(不满足"关键修复"标准)
那么只能自己 cherry-pick 成补丁。
场景一:安全修复的等待期。
Chromium 发现一个漏洞,在 main 上修了。但按政策,这个修复要等下一个 stable 版本才公开。Electron 如果跟的是 beta 分支,就得等;如果跟的是 stable,就要看 M156 什么时候发补丁版。
这个等待期可能是几天到几周。Electron 如果认为风险不可接受,就 cherry-pick。
场景二:崩溃修复。
某个 GPU 驱动上的崩溃,Chromium 在 main 上修了,但 merge 到 M156 被拒(因为不满足"影响足够多用户"的标准)。Electron 的用户可能正好用那个驱动。
场景三:Electron 特有的组合。
某个 bug 只在 "Chromium + Node 在同一个进程" 的组合下出现,Chromium 不认为是 bug,但 Electron 必须修。
4.18.3 风险:静默的语义漂移
cherry-pick 的最大风险不是冲突,是上下文缺失。
一个 commit 在 main 上可能是这样一串改动中的一环:
sql
commit A: 重构 X,引入新抽象
commit B: 基于 X 的新抽象,修复 bug
commit C: 清理 X 的旧调用点
如果 Electron 只 cherry-pick B,而 A 和 C 不在,那么 B 的改动可能建立在不存在的抽象上。git 会尝试三方合并,可能成功(如果 B 只改了局部),但语义是错的。
这类问题在 cherry-pick 补丁上尤其常见,因为补丁作者通常只关心"我要的那个修复",不关心它的前置依赖。
Electron 的应对方式是:cherry-pick 时尽量只选自包含的、改动局部的 commit。如果一个修复依赖一串前置重构,那就不 cherry-pick,等上游 merge。
4.18.4 一个可操作的检查方法
如果你在审计一个 cherry-pick 补丁,可以这样验证:
bash
# 1. 拿到原始 commit 的信息
cd /path/to/chromium/src
git show --stat 9c58c948fa47
# 2. 看它改了哪些文件,是否和补丁一致
# 3. 看它的 parent,判断是否有前置依赖
git log --oneline 9c58c948fa47~3..9c58c948fa47
# 4. 确认它是否已经在当前跟随的分支上
git branch -a --contains 9c58c948fa47
第 4 步是关键:如果 git branch --contains 显示它已经在 M156 上了,这个补丁就可以删。
这也是"补丁债"的还款路径:每隔几个版本,把已在目标分支上的 cherry-pick 补丁删掉。
4.19 补丁的生命周期:五种操作
patches.md:46-49 给了四种操作的流程,加上"冲突解决"是五种。
4.19.1 新增
patches.md:46-49
bash
$ cd src/third_party/electron_node
$ vim some/code/file.cc
$ git commit
$ ../../electron/script/git-export-patches -o ../../electron/patches/node
三个要点:
要点一:必须 commit。
git-export-patchesignores any uncommitted files, so you must create a commit if you want your changes to be exported.
这个约束不是限制,是设计。因为导出是"把 commit 变成 patch 文件",而 patch 文件的格式就是 git format-patch 的格式------包含 author、date、subject、body。
要点二:subject 会变成文件名。
The subject line of the commit message will be used to derive the patch file name, and the body of the commit message should include the reason for the patch's existence.
所以 subject 要写成"动词开头、能当文件名"的形式。看现有补丁的 subject:
vbnet
build: add GN build files
Pass all globals through "require"
feat: key code caches to the patch set and the embedded blob
fix: expose the built-in electron module via the ESM loader
chore: revert [OSCrypt] Remove sync backend
前四个都用了 <type>: <desc> 的约定式提交格式,Pass all globals through "require" 是老补丁没遵守。
文件名会做什么处理?空格变下划线、引号去掉、转小写。所以 Pass all globals through "require" 变成 pass_all_globals_through_require.patch:4。
要点三:body 必须写理由。
这是 4.1 节那三条理由的落地位置。没有 body 的补丁,在 code review 时会被打回。
4.19.2 编辑
patches.md:60-65
bash
$ cd src/v8
$ vim some/code/file.cc
$ git log
# Find the commit sha of the patch you want to edit.
$ git commit --fixup [COMMIT_SHA]
$ git rebase --autosquash -i [COMMIT_SHA]^
$ ../electron/script/git-export-patches -o ../electron/patches/v8
--fixup + --autosquash 是 git 的标准技巧:
git commit --fixup ABC创建一个 subject 为fixup! <原 subject>的 commitgit rebase --autosquash会自动把fixup!commit 合并到目标 commit,且丢弃它的 message
这样"改一个老补丁"就变成了"在原位置修改",而不是"在末尾追加一个修补补丁"。
如果用后者,patches/v8/ 里会出现两个文件:原补丁 + fix_xxx.patch。看代码的人要读完两个才知道最终状态。--fixup 避免了这个问题。
文档还提醒了一个 Windows 上的坑:
Note that the
^symbol can cause trouble on Windows. The workaround is to either quote it"[COMMIT_SHA]^"or avoid it[COMMIT_SHA]~1.
原因是 Windows 的 cmd.exe 用 ^ 做转义字符。在 Git Bash 里没问题,但如果 git 被配成走 cmd,^ 会被吃掉。
4.19.3 删除
bash
$ vim src/electron/patches/node/.patches # 删掉那一行
$ cd src/third_party/electron_node
$ git reset --hard refs/patches/upstream-head
$ ../../electron/script/git-import-patches ../../electron/patches/node
$ ../../electron/script/git-export-patches -o ../../electron/patches/node
四步,但第一步和第四步之间是一个完整的重建。
git reset --hard 之后,工作区回到纯净上游。然后重放剩下的补丁。如果某个补丁依赖被删的那个,重放在那一步失败。
失败信息会告诉你哪个补丁、哪个文件、哪一行。这就是"删除的安全性检查"------依赖关系在重建时暴露,而不是在运行时崩溃。
第四步 git-export-patches 是必须的吗?严格说不必须------.patches 已经改了,剩下三个步骤就是改仓库状态。但要导出是因为:重放过程中可能会有"补丁的 index 行变了"(因为前面的补丁变了,后续补丁的 base blob hash 变了)。导出会把新的 hash 写回补丁文件,让下一次 import 能干净应用。
文档也提到了这一点:
Re-exporting patches will sometimes cause shasums in unrelated patches to change. This is generally harmless and can be ignored (but go ahead and add those changes to your PR, it'll stop them from showing up for other people).
"无害但会产生 diff 噪音"。建议是:把这些 hash 变化一起提交,免得下次别人再遇到。
4.19.4 升级上游
这是最复杂、文档里没直接写流程的操作。实际步骤是:
- 改
DEPS里的 Chromium/Node/V8 版本 gclient sync- 对每个补丁集执行
git-import-patches(带-3) - 逐个解决冲突
git-export-patches导出- 构建、测试
第 4 步是全部工作量的所在。一次 Chromium 大版本升级,可能有 30-50 个补丁需要人工介入。
经验性的优先级:
- 先解决
build_gn.patch:4和所有build_*.patch:构建不通,什么都测不了 - 再解决
content/层的补丁:这些是功能基础 - 最后解决
chrome/browser/层的(打印、单实例、通知):这些是功能增强,坏了不影响启动
4.19.5 冲突解决
patches.md:89-91
bash
$ cd src/third_party/electron_node
$ git am --abort
$ ../../electron/script/git-import-patches -3 ../../electron/patches/node
--abort 是关键的第一步:如果上一次 import 中途失败,git am 处于"正在应用"的状态,不 abort 就无法开始新的 import。
然后带 -3 重试。如果 -3 也失败,git am 会停下来,进入交互式冲突解决:
bash
# 解决冲突文件
$ vim lib/internal/modules/cjs/loader.js
$ git add lib/internal/modules/cjs/loader.js
$ git am --continue
git am --continue 会继续应用后续的补丁。这一点很重要:你解决的是当前这一个,后面的还没开始。
4.20 冲突的四种形态
升级时遇到的冲突不是同一种东西。分清形态,解决方案完全不同。
形态一:上下文漂移
less
error: patch failed: content/browser/web_contents/web_contents_impl.cc:5806
error: content/browser/web_contents/web_contents_impl.cc: patch does not apply
现象:行号对不上,但代码内容还在。
原因:上游在补丁位置附近增删了行。
解决 :-3 三方合并通常能自动处理。如果不能,手工找位置,把 + 行插进去。
风险:低。这是最良性的一种。
形态二:代码被重构
bash
error: patch failed: gin/v8_initializer.cc:511
打开文件发现:V8Initializer::Initialize 被拆成了两个函数,或者参数从 4 个变成 5 个。
原因:上游重构了这段代码。
解决 :不能机械地"找位置插入",必须理解补丁的意图,然后在新结构里实现同样的意图。
风险:中高。机械解决会引入语义错误。
例子 :gin_enable_disable_v8_platform.patch 给 Initialize 加了一个 create_v8_platform 参数。如果上游把这个函数改成接受一个 Options 结构体,补丁就要改成往结构体里加字段,而不是加参数。
形态三:功能被上游实现
makefile
error: patch failed: ...
# 打开文件发现:上游已经加了你要加的那行
原因:你等的修复,上游终于自己做了。
解决:**删掉这个补丁。**不是解决冲突,是删除。
风险:低,但要确认"上游的实现和你的实现语义一致"。有时候上游实现了 80%,剩下的 20% 还是需要补丁。
这是最好的冲突------它意味着补丁债被还了。
形态四:静默失效
没有报错。git am 干净通过,编译通过,测试通过。但功能坏了。
原因:三种可能
- 补丁改的是一段"现在不再被调用"的代码
- 补丁加的钩子现在没人调用了
- 补丁依赖的某个前提变了(比如某个函数现在默认参数不同)
现象:用户报告"某个功能不工作了",而 CI 全绿。
解决 :只能靠测试覆盖发现。没有测试的功能,静默失效会一直存在到用户报告。
这是最危险的形态,也是 4.1 节那句 "sometimes without even a patch conflict or a compilation error" 的所指。
4.20.1 如何防御形态四
三个层次的措施:
层次一:给每个补丁对应的功能写测试。
Electron 的 spec/ 目录里有大量测试,很多就是为了守住某个补丁的行为。比如 api-js2c-code-cache-spec.ts 就是为 4.13 节的代码缓存补丁写的。
层次二:在补丁里加断言。
如果补丁加了一个钩子,在钩子里加一个 DCHECK 或者日志,让"钩子没被调用"变成可观测的。
层次三:定期审计。
每隔几个版本,抽查一批补丁,确认它们改的代码还在被调用。方法:在补丁加的行上设断点,跑一遍应用,看断点是否命中。
这三个层次的成本递增,覆盖率递减。层次一是唯一可持续的。
4.21 审计一个补丁:五问法
当你面对一个陌生的补丁(可能来自别人的 PR,可能是升级时需要判断"这个还要不要"),按这五个问题走。
问题一:它改的是哪个仓库的哪个文件?
看 diff --git a/... b/... 的路径,和 config.json 里的 repo 对照。
css
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc
content/ 前缀 + chromium 补丁集 + repo: src → 改的是 Chromium 的 content 层。
这个信息决定了很多后续判断:content 层的代码比 chrome/browser/ 稳定(因为 content 是"浏览器内核",chrome 是"Chrome 这个产品"),但比 base/ 不稳定。
问题二:是新增还是修改?
看 + / - 的比例。
- 纯新增(0 删除):新增了能力或恢复了被删的代码
- 纯删除(0 新增):移除了某种行为
- 增删相当:替换实现
revert_oscrypt_remove_sync_backend.patch 是 6,232 增 / 0 删------纯恢复。 build_gn.patch 是 3 增 / 0 删------纯新增。 blink_local_frame.patch 是增删都有------替换。
问题三:改的位置是"入口"还是"实现"?
入口:函数签名、接口定义、虚函数表。改这里影响面大,但冲突时容易发现(编译报错)。
实现:函数体内部。改这里影响面小,但冲突时可能静默。
gin_enable_disable_v8_platform.patch 改的是入口(Initialize 的签名)。 can_create_window.patch 改的是实现(CreateNewWindow 的函数体)。
问题四:有没有对应的测试?
在 spec/ 目录里 grep 补丁涉及的 API 名。
有测试 → 补丁的行为有保障,升级时跑测试能发现形态四的失效。 没测试 → 需要人工验证。
Electron 的测试覆盖不均匀:BrowserWindow、webContents、ipc 这些核心 API 有大量测试;OSR、打印、某些 macOS 特性测试较少。
问题五:commit message 属于三条理由的哪一条?
- 理由一(临时):有上游链接,有死亡条件 → 低维护成本
- 理由二(编译适配):解释了为什么不能继承/复制 → 中维护成本
- 理由三(功能对立):最稳定,但也最难移除 → 长期存在
如果 commit message 什么都没说,这个补丁就是一个未知负债。审计结论应该是"要求补充说明"。
4.21.1 一个完整的审计示例
拿 can_create_window.patch 走一遍:
问题一 :改 content/browser/renderer_host/render_frame_host_impl.cc 和 content/browser/web_contents/web_contents_impl.cc。都在 content 层,稳定度中等。
问题二 :增删都有。CreateNewWindow 里加了 5 行(no_javascript_access 的判断),删了 7 行(旧的 WebContentsCreated 回调)。是"替换实现"。
问题三 :MaybeOverrideCreateParamsForNewWindow 是新增的虚函数------改的是接口 。WebContentsCreatedWithFullParams 也是。所以这个补丁动了两处接口。
接口改动的含义:如果上游的 WebContentsDelegate 加了新的虚函数,Electron 的 ElectronBrowserClient(实现这个接口的类)需要跟着实现。而 Chromium 的虚函数通常是纯虚 还是有默认实现,决定了这是编译错误还是静默失效。
问题四 :spec/ 里有 api-browser-window-spec.ts 里的 window.open 测试,有 web-contents-spec.ts 里的 setWindowOpenHandler 测试。覆盖较好。
问题五 :commit message 说了两件事(拦截窗口创建、no_javascript_access 影响 opener),属于理由三(功能对立)。但 commit message 里那句 TODO(loc): this patch is currently broken. 是一个红旗。
审计结论 :这个补丁是长期负债,无法移除。TODO 标记的破损路径需要确认影响范围。测试覆盖尚可,但 raw_features / body 的透传路径可能没有测试。
4.22 七个常见误解
误解一:"Electron 是 Chromium 的 fork"
不是。Electron 从不修改 Chromium 的 git 历史,它是在一个纯净的 Chromium checkout 上应用 380 个补丁。config.json 和 .patches 就是这个"应用"的完整描述。
这个区别的实际意义:Electron 可以随时切换 Chromium 版本,只需要重新解决补丁冲突。如果是 fork,切换版本意味着 rebase 几十万个 commit。
误解二:"补丁越多说明改得越多"
不对。380 个补丁里,127 个是 cherry-pick(把上游的修复提前拿过来),64 个是 fix(修 Electron 自己的 bug),只有 27 个 feat 是"新增能力"。
真正"Electron 独有的功能改动"可能只有几十个。剩下的都是"适配"和"追平"。
误解三:"补丁行数代表重要性"
反例:node/reland_temporal_unflag_temporal.patch:4-16,19 行、1 行新增,决定 Temporal API 在不在。
再反例:nan/chore_add_yarnrc_yml...yarn_v4.patch,2,711 行,只是为了让 yarn 用对版本。
行数是维护成本的代理,不是影响力的代理。
误解四:"补丁可以随便重排"
不行。补丁之间有依赖:后一个补丁的 diff 上下文可能建立在前一个补丁的输出上(看 index 行的 blob hash)。
.patches 的线性顺序是一个拓扑排序。删除需要重建验证,重排几乎一定失败。
误解五:"上游合了就能删补丁"
要确认"合的是同一个东西"。上游可能实现了 80%,剩下的 20% 仍需要补丁。也可能上游的实现语义不同(比如默认值不一样)。
正确做法:上游合了之后,先在本地删掉补丁试一次,跑测试,确认没有行为变化,再提交删除。
误解六:"补丁系统的目的是修改上游"
不是。目的是用最小的改动让上游代码在 Electron 的环境里工作。
patches.md 说得很清楚:"we strive to keep our patch count at a minimum"。补丁是失败的结果,不是成就。
误解七:"MAS 补丁只影响 App Store 版本"
不对。is_mas_build 是编译期开关,MAS 构建会走不同的代码路径(stub killring、无 CGSSetWindowCaptureExcludeShape)。这意味着MAS 版本和普通版本的行为不完全一致。
如果你在开发一个要上架 MAS 的应用,要专门测 MAS 构建------某些功能(屏幕共享时排除自己窗口、Ctrl+K)在那里是缺失的。
4.23 本章小结
380 个补丁,77,519 行。这个数字背后是九个接缝:
接缝一:构建。 build_gn.patch:17 三行把 is_electron_build / is_mas_build 放进全局作用域,让全树的 BUILD.gn 都能看见。这是所有其它补丁的前提。
接缝二:窗口创建。 can_create_window.patch 在 CreateParams 构造完成、WebContents 实例化之前插入钩子,让应用能决定新窗口的配置。这是 Electron "应用决定一切"哲学的技术基础。
接缝三:V8 平台。 gin_enable_disable_v8_platform.patch 加一个 bool 参数,把"谁创建 V8 平台"从编译期默认改成运行期决策。这是 Node 和 Chromium 共存的必要条件。
接缝四:帧生命周期。 blink_local_frame.patch:8 回退上游三次改动,保住 willReleaseScriptContext 里的清理时机。教训是:不要依赖网页可见事件做框架清理。
接缝五:视图注入。 web_contents.patch 13 行,让 CreateParams.view 真正被读取。这是 OSR 的唯一入口。
接缝六:进程单例。 两个补丁,一个做平台适配(Windows mutex、macOS 沙箱路径、UIPI),一个把 32 KiB 固定缓冲改成增长缓冲。后者是一次真实的工程事故复盘。
接缝七:Linux 沙箱。 support_mixed_sandbox_with_zygote.patch 把 --no-zygote 从"全局关闭"改成"逐进程判断",让同一应用能混合沙箱。配套的 allow_new_privs 让无沙箱进程能 exec setuid 程序。
接缝八:Node 集成。 六个关键补丁:GN 构建文件、全局变量穿透 require、ESM 三兄弟。它们定义了"Electron 里的 Node 是什么样"。
接缝九:平台合规。 mas_avoid_private_macos_api_usage.patch.patch 2,538 行、68 文件、22 个私有 API。这是 App Store 审核逼出来的。
三条贯穿全章的方法论:
**第一,补丁是债,不是资产。**每个补丁都必须写明三条理由之一,必须有死亡条件。cherry-pick-<sha> 的命名就是这个原则的最佳实践------把死亡条件编码进文件名。
第二,补丁是有向图。 .patches 的线性顺序是拓扑排序。删除比重排安全,重排几乎一定失败。reset --hard + 完整重放是唯一可靠的删除方式。
**第三,静默失效是最大的敌人。**上下文漂移会报错,代码重构会报错,功能被上游实现会"报错"(其实是好消息)。真正危险的是 apply 成功、编译通过、测试通过、但功能坏了。防御它的唯一手段是测试覆盖。
下一章开始进入 Electron 自己的代码------shell/ 目录。我们会从 app.whenReady() 往回追,看一次 ready 事件背后到底跑了多少个阶段。
4.24 练习题
练习 4.1(入门) 在仓库根目录执行 cat patches/chromium/.patches | wc -l,得到 241。然后用 ls patches/chromium/*.patch | wc -l 数文件数,两者应该相等。如果不相等,说明什么?
练习 4.2(入门) 找出 patches/ 下所有 0 行删除(纯新增)的补丁。它们的 commit message 有什么共同特征?
练习 4.3(进阶) build_gn.patch:13 的 diff 头是 @@ -123,6 +123,9 @@。在 Chromium 源码里找到 build/config/BUILDCONFIG.gn 的第 123 行,确认它当前是什么内容。如果这个补丁在升级后应用失败,你会怎么定位新位置?
练习 4.4(进阶) 阅读 build_add_gn_build_files.patch 里关于 node_path 的说明。用一个小 GN 文件复现这个陷阱:在 declare_args() 里让变量 A 的默认值依赖变量 B,然后在 args.gn 里同时覆盖 A 和 B,观察 gn gen 是否仍然用 B 的默认值求值 A。
练习 4.5(进阶) web_contents.patch 里的判断是 if (params.view && params.delegate_view)------逻辑与。如果改成逻辑或,会发生什么?请从 RenderViewHostDelegateView 的接口定义出发分析。
练习 4.6(深入) 4.13 节说 V8 的盐是在 gn gen 时计算的。在 feat_key_code_caches_to_the_patch_set_and_the_embedded_blob.patch:86 里找到生成那个头文件的 GN 代码,确认它用的是 exec_script 还是 GN 内建的哈希函数。
练习 4.7(深入) revert_oscrypt_remove_sync_backend.patch 是 6,232 增 / 0 删。既然 git am 是按 diff 应用的,一个只含 new file mode 的补丁如何工作?请解释 --- /dev/null 和 +++ b/components/os_crypt/sync/BUILD.gn 这对标记的含义。
练习 4.8(深入) 设计一个测试,能发现 4.20 节"形态四:静默失效"里的第 2 种情况(补丁加的钩子没人调用了)。参考 4.13 节 SetProcessDefaultCodeCache 的 DCHECK 记录器设计。
练习 4.9(挑战) 从 380 个补丁里选一个 revert 类的(共 6 个),回答:它 revert 的是哪个上游 CL?那个 CL 为什么被上游接受?如果上游不撤回那个 CL,Electron 有什么替代方案(不用 revert)?
练习 4.10(挑战) 假设你要给 Electron 加一个功能:让 webContents.print() 支持"打印到指定文件而不弹对话框"。判断这个功能需不需要打补丁。如果需要,应该改哪个补丁?如果不需要,用哪个已有 API?
4.25 下一章预告
第 5 章《主进程启动链路》会从 app.whenReady() 开始,往回追到 main()。
你会看到:
electron_main_delegate.cc:373里的PreSandboxStartup、PreCreateMainMessageLoop、PostEarlyInitialization三个阶段各做什么ElectronBrowserMainParts的十二个Pre/Post钩子,以及它们与 ChromiumContentMainRunner的对应关系node_bindings.cc里NodeBindings::CreateEnvironment的完整时序------包括 4.7 节说的"谁来InitializePlatform"在那个时序里的确切位置lib/browser/init.ts如何把 C++ 侧准备好的环境交给 JS,以及JoinAppCode里的那个 uv loop 泵
那一章会解释一个很多人困惑的问题:为什么 app.whenReady() 在 main.js 顶层调用时,回调不会立刻执行,但 require('electron') 却已经能拿到完整的 API?
答案在 node_bindings.cc:896 的 BootstrapEnvironment 和 lib/browser/init.ts 的 startup() 之间的那个 setImmediate。
4.26 本章源码落点索引
下列位置全部来自本仓库源码树,按实现层归集;每个
路径:行号指向该文件中与本章论述对应的声明或实现行,可逐条复核。
- patches/angle :
cherry-pick-2cfdd6153dcf.patch:4 cherry-pick-66a2f18a84d3.patch:4 cherry-pick-7bf673433dd1.patch:4 cherry-pick-85e50e76a6db.patch:4 cherry-pick-a1550b2077bf.patch:4 cherry-pick-a66226b1d3c0.patch:4 cherry-pick-afa978f8ca67.patch:4 - patches/chromium :
cherry-pick-012bb9c30a76.patch:4 cherry-pick-01d1d799673f.patch:4 cherry-pick-041491e936ea.patch:4 cherry-pick-08887dc29edc.patch:4 cherry-pick-08f529c57faa.patch:4 cherry-pick-0a8c55898c38.patch:4 cherry-pick-0c52161c15e7.patch:4 cherry-pick-0f12a22f1263.patch:4 -
cherry-pick-109fd0a12b2c.patch:4 cherry-pick-14367349ae1f.patch:4 cherry-pick-21c3fb688656.patch:4 cherry-pick-22924b20892f.patch:4 cherry-pick-23573991afa3.patch:4 cherry-pick-2bbd07a96ab2.patch:4 cherry-pick-2def58ebf816.patch:4 cherry-pick-3318e836e90b.patch:4 -
cherry-pick-403a04e98d9f.patch:4 cherry-pick-41e502b417de.patch:4 cherry-pick-431b8be6a111.patch:4 cherry-pick-4af2a7b4ef63.patch:4 - patches/v8 :
cherry-pick-0113334b37f5.patch:4 cherry-pick-027cd7274886.patch:4 cherry-pick-0873d88995f7.patch:4 cherry-pick-0a9d0f8af155.patch:4 cherry-pick-0efaa2c85aa6.patch:4 cherry-pick-12594aa9a78a.patch:4 cherry-pick-1c324a029075.patch:4 cherry-pick-1f76a38270ac.patch:4 -
cherry-pick-24813965d63d.patch:4 cherry-pick-32bb3bf0bbd5.patch:4 cherry-pick-331aa200f283.patch:4 cherry-pick-42d78d0f3d99.patch:4 cherry-pick-55bfd9067f97.patch:4 cherry-pick-56d94ea65ee3.patch:4 cherry-pick-618b7515c679.patch:4 cherry-pick-62ebf3aa06e2.patch:4 -
cherry-pick-6da8c3ded79a.patch:4 cherry-pick-77e9bee7705f.patch:4 cherry-pick-8232f38f92c2.patch:4 cherry-pick-8ddaab361e3d.patch:4 - patches/dawn :
cherry-pick-644b9b3b57a2.patch:4 cherry-pick-77f9a9aa5f71.patch:4 cherry-pick-8ce9806ee580.patch:4 cherry-pick-de87968dc43d.patch:4 - patches/ffmpeg :
cherry-pick-5eed87f44b0a.patch:4 cherry-pick-f4bee18dadeb.patch:4 cherry-pick-fb424c4f78d1.patch:4 - patches/pdfium :
cherry-pick-0a21f66dd0c6.patch:4 cherry-pick-4150fb564e0d.patch:4 cherry-pick-cce5c5ed801f.patch:4 - patches/skia :
cherry-pick-1c9382b333e2.patch:4 cherry-pick-28e2362fdfc2.patch:4 cherry-pick-78a55b4b9e62.patch:4 cherry-pick-9f8d44990bc0.patch:4 cherry-pick-c433a0fce940.patch:4 cherry-pick-ffae953f7ec8.patch:4 - 浏览器进程 C++ 层 :
inspectable_web_contents_delegate.h:12 inspectable_web_contents_view_delegate.cc:10 inspectable_web_contents_view_delegate.h:15 electron_api_web_contents.cc:249 electron_api_web_contents.h:61 electron_api_web_contents_impl.cc:19 electron_api_web_contents_mac.mm:16 electron_api_web_contents_view.cc:38 -
electron_api_web_contents_view.h:17 child_web_contents_tracker.cc:13 child_web_contents_tracker.h:16 electron_web_contents_utility_handler_impl.cc:23 electron_web_contents_utility_handler_impl.h:15 extended_web_contents_observer.h:13 electron_extension_web_contents_observer.cc:10 electron_extension_web_contents_observer.h:14 -
file_system_access_web_contents_helper.cc:11 file_system_access_web_contents_helper.h:11 osr_web_contents_view.cc:18 osr_web_contents_view.h:20 osr_web_contents_view_mac.mm:9 preload_code_cache.cc:38 preload_code_cache.h:18 inspectable_web_contents.cc:111 - shell 其余实现 :
node_natives_code_cache.cc:16 node_natives_code_cache.h:11 node_natives_code_cache_internal.h:11 node_natives_code_cache_stub.cc:21 web_contents_utility.mojom:8 - API 文档与类型 :
web-contents-view.md:5 web-contents.md:5 - JS 门面与运行时 :
web-contents.ts:20