上篇《把 Electron 应用搬进鸿蒙的 8 个坑》结尾,我留了个「绕不过去的坎」:鸿蒙应用域里,spawn 一个 node / pnpm 子进程,会被 seccomp 直接 SIGSYS 杀掉。
当时我把它归为「平台级无解」。今天这篇要打自己的脸:那个坎不是无解,换个思路就过去了。 插件市场的一键安装,现在已经在鸿蒙真机上跑通。
这篇文章讲清三件事:为什么 spawn 必死、怎么用「进程内 pnpm」绕过去、真机上又踩了哪三个坑。如果你也在鸿蒙上折腾 Node 生态的包管理,这套思路可以直接抄。
一、问题:为什么 spawn 一个 node 就死
先把这个「坎」的硬度说清楚。
我在鸿蒙 2in1 真机上、应用进程里、同一个 SELinux 域下,实测了一组命令:
| 命令 | 结果 |
|---|---|
node -v |
✅ v24.13.0 |
node -e "console.log(1)" |
❌ Signal 31(SIGSYS) |
node --jitless -e "..." |
❌ SIGSYS |
npm -v |
❌ SIGSYS |
python3 -c "print('ok')" |
✅ |
看懂这组数据,关键在于三个「不是」:
- 不是「不能执行代码」------python3 连同多线程都正常跑;
- 不是「node 坏了」 ------
node -v能正常打印版本; - 也不是文件沙箱、权限、路径问题------放宽文件策略后结果不变。
真正的原因是:应用域里 fork 出的子进程,只要尝试分配「JIT 可执行内存」,就会被内核 seccomp 策略投递 SIGSYS。V8 一创建 isolate(真正开始编译执行 JS),必死。
而 pnpm、npm、corepack------全都是 node 脚本。所以「把 pnpm 装到 PATH 上让市场调用」这条路,跟 pnpm 装在哪、PATH 怎么配,一点关系都没有,天生走不通。
二、排查:三个假象,一个真凶
排查过程我踩了三个坑,前两个都是假象,差点把我带沟里。
假象一:pnpm@12 装不上。
报错 pnpm does not ship a prebuilt binary for openharmony-arm64。因为 pnpm 从 v12 起改成了 Rust 原生分发壳,平台白名单里压根没有 openharmony。解法是退回纯 JS 的 pnpm@11 / 10------但这只是「装上了」,离「能用」还差十万八千里。
假象二:PATH 不生效。
写 ~/.profile 没用,因为鸿蒙终端是 zsh,只读 ~/.zshenv。这个坑花了不少时间,解决后 pnpm 在终端里能跑了,应用里还是不行。
真凶:就是上面那组 SIGSYS 实测。
终端(真实 shell)能跑 node/pnpm,应用域里的子进程却全崩------差别就在 seccomp 只针对应用域 fork 出的子进程。
三、洞察:SIGSYS 只杀子进程,不杀主进程
卡住我的那堵墙,其实有个缺口:
SIGSYS 只杀「fork 出的子进程」。而 Electron 主进程自身,就是 Node 22.17,能正常跑 JS。
既然主进程能跑 JS,那我不 spawn 子进程,把 pnpm 的 JS 直接 import 进主进程里跑,不就把 seccomp 绕开了?
这就是整个方案的立足点------「进程内 pnpm」。思路一换,墙就没了。
四、方案:把 pnpm 塞进主进程,用 worker 线程跑
方案定了,落地有三个关键决策。
决策一:版本退回 pnpm@10.34.6。
pnpm@11 是纯 ESM,但它依赖 node:sqlite,而 Electron/Node 运行时没有这个 binding,import 直接报 No such binding: sqlite。所以退回 pnpm@10.34.6(纯 CJS、不碰 node:sqlite)。
决策二:丢进 worker 线程,而不是直接 import。
直接 import pnpm 有个坑:pnpm 的 main 是「火忘式」异步、从不调 process.exit,你根本没法知道它装完了没;更危险的是,它内部可能调 process.exit,在进程内跑会直接杀掉 Electron 主进程。
解法是把它丢进 worker_threads.Worker,一次解决三件事:
- 完成检测:worker 事件循环排空 → 退出,退出码就是 pnpm 的退出码;
process.exit隔离:只会退出 worker,不杀主进程;- ESM 缓存 :每次
new Worker都是全新模块图,不用管缓存击穿。
决策三:扁平布局,绕开 symlink / hardlink 禁令。
pnpm 默认 node-linker=isolated,会在 node_modules 里建 symlink、在 store 里建 hardlink------鸿蒙沙箱两者都禁。所以必须:
nodeLinker: hoisted:生成扁平 node_modules,无 symlink、无.pnpm虚拟 store;packageImportMethod: copy:从 store 拷贝文件而非 hardlink;storeDir指向沙箱内可写目录。
五、真机上的三个修复
设计想得再周全,真机一跑还是崩出三个问题,逐个修:
| # | 问题 | 现象 | 修复 |
|---|---|---|---|
| P1 | pnpm@11 依赖 node:sqlite |
import 抛 No such binding: sqlite |
引擎退回 pnpm@10.34.6 |
| P2 | Electron 的 process.execPath 只读 |
pnpm 配置里无条件 process.execPath = node → 抛 Cannot assign to read only property |
worker import 前把 process.execPath 设成 no-op setter(保留真实值) |
| P3 | 鸿蒙禁 symlink,pnpm 仍建 .bin 软链 |
EACCES: symlink ... -> .bin/... → 安装失败 |
worker 里把 fs.symlink 在 EACCES/EPERM 时回退为 copy ,.bin 落成真实文件 |
三个修复都不复杂,但每一个都是「真机才暴露、设计时想不到」的坑。
六、结果:一键安装,真机跑通
修完这三个,端到端通了。市场一键安装 / 卸载插件,全程:
- 无 node / pnpm 子进程------日志里的 pid 就是应用主进程;
- 无 symlink------profile 的 node_modules 递归扫描 symlinkCount = 0;
- 零 ELF、零二进制证书、零额外 ACL。
装了个插件试水,exit=0, hot=true, live------装上即热加载。
写在最后
回头看,这个「绕不过去的坎」其实不是坎,是个思路问题:
你以为「跑 pnpm」只能 spawn 子进程,但鸿蒙的 seccomp 只杀子进程、不杀主进程------换个「进程内」的角度,墙就没了。
如果你也在鸿蒙上折腾 Node 生态的包管理,这套「worker 线程进程内跑 pnpm + hoisted + symlink→copy」的思路,可以直接抄。
代码已开源,欢迎 star:
- 鸿蒙工程:https://github.com/fellow99/dsh-desktop-hos
- 桌面工程:https://github.com/fellow99/dsh-desktop
- 工程总览:https://github.com/fellow99/deepseek-harness-workspace
- 上游 DeepSeek Harness:https://github.com/deepseek-ai/deepseek-harness
- 插件市场:https://github.com/dsh-market/dsh-market
- 鸿蒙 Electron 运行时:https://atomgit.com/jianguoxu/harmonypc-electron
下一篇我写 App 上架合规踩坑:被 AppGallery 打回 12 条整改,从商标撞名到 ArkWeb 合规。关注我,别错过。
感谢各位关注,欢迎访问我的 GitHub 主页:https://fellow99.github.io/