FreeBSD linuxulator 跑小浣熊客户端:登录页一直转不回来的完整排查记录
环境:FreeBSD 15.1 + Ubuntu 22.04 chroot(Linuxulator)+ 商汤小浣熊 1.0.38(Electron 41)
关键词:Electron 自定义协议、深链回调、chroot、zygote、EBADF、SingletonLock
TL;DR
小浣熊客户端在 FreeBSD 的 Linux 兼容层里能跑起来、能弹出浏览器登录页、网页也能登录成功,但客户端永远停在登录界面。
真正的原因有两层,都不是网络问题:
- 登录成功后浏览器要跳转
office-raccoon://auth/callback?code=...,这个深链靠桌面项拉起客户端转交参数。而官方桌面项写的是Exec=/opt/office-raccoon/raccoon-ai %U,缺--no-sandbox。在 linuxulator 下 Chromium 的 zygote 沙箱根本起不来,回调实例直接FATAL崩溃,回调永远送不到主实例。 - 补上
--no-sandbox之后还有第二个坑:浏览器拉起外部程序时把子进程的 fd 1/2 关掉了 ,electron-log 往已关闭的 fd 写,抛EBADF: bad file descriptor,进程卡死不退出。更糟的是这些卡死实例会顺手删掉主实例的SingletonLock/SingletonSocket,把后面的启动全带偏。
最终解决方案:一个包装脚本(补 --no-sandbox + 重定向 stdout/stderr)+ 用户级桌面项覆盖 + MIME 关联 + Firefox 免确认,外加清理残留进程重开。登录最终是通过客户端自带的"兼容登录"(loopback)通道完成的。
一、背景:为什么要在 FreeBSD 上跑 Linux 版客户端
我日常主力是 FreeBSD(15.1-RELEASE-p1),但小浣熊桌面端只提供 Linux(deb)和 Windows 版本。FreeBSD 的 Linuxulator 现在成熟度不错,配合一个 Ubuntu 22.04 的 chroot(/compat/ubuntu),绝大多数 Electron 应用都能跑起来。
启动脚本(/usr/local/bin/raccoon-chroot)的核心就是把 X11 的 cookie 带进 chroot,然后以普通用户身份起 Electron:
exec sudo chroot /compat/ubuntu /bin/bash -c \
'exec su skywalk -c "export NEXT_PUBLIC_DESKTOP_APP_MODE=online_plus \
NEXT_PUBLIC_DESKTOP_CLIENT_AUTH=true \
NEXT_PUBLIC_DESKTOP_TOKEN_BRIDGE=true \
NEXT_PUBLIC_DESKTOP_REMOTE_MAIN_SITE_URL=https://xiaohuanxiong.com \
NEXT_PUBLIC_DESKTOP_REMOTE_API_URL=https://xiaohuanxiong.com; \
DISPLAY=:0 XAUTHORITY=/home/skywalk/.Xauthority exec raccoon-ai --no-sandbox $@"' _ "$@"
注意这里已经带了 --no-sandbox ------ 这是 linuxulator 的老朋友了(FreeBSD 没有 user namespace,Chromium 的沙箱没法用)。
应用本体、渲染进程、GPU 进程都正常,界面能出来,这是能继续排查的前提。
二、现象
- 点击客户端登录页的"注册/登录" → 正常弹出浏览器(chroot 里的 Ubuntu Firefox)
- 浏览器里账号密码登录 → 网页登录成功
- 然后......没有然后了,客户端仍然停在登录墙
典型的"最后一公里"问题:前端流程全绿,回调链路断了。
第一反应通常是网络(客户端要 POST https://xiaohuanxiong.com/api/web/auth/v1/login_with_authorization_code 换 token)。先排除:
# 日志里能看到接口真实返回,说明出网没问题
"url": "https://xiaohuanxiong.com/api/web/auth/v1/login_with_authorization_code",
"httpStatus": 400, "responseCode": 200035
能拿到服务端 400(因为 code 是我瞎编的),说明 TLS、DNS、出站全部正常。问题在客户端和浏览器之间。
三、先看客户端到底在等什么
3.1 日志
客户端用的是 electron-log,日志在:
~/.config/office-raccoon/logs/raccoon.log
注意:在 chroot 里 ~ 是 /compat/ubuntu/home/skywalk,和宿主机的 /home/skywalk 不是同一份目录(两者设备号不同,别找错地方)。
正常应该出现的关键行是:
[desktop-auth] browser launch requested
[desktop-auth] callback received { hasCode: true, loggingIn: false }
而故障时只有第一行,永远等不到 callback received。这就把范围缩小到"浏览器 → 客户端"这一段。
3.2 端口
顺手看一眼有没有本地监听(有些 Electron 应用会用本地 HTTP 服务接回调):
sockstat -4l
结果里没有任何 raccoon 的监听端口。也就是说默认登录流不靠本地端口,靠的是自定义协议深链。
3.3 从 app.asar 里挖出真实流程
Electron 的代码全在 resources/app.asar(155 MB)。不需要解包,直接 grep 二进制就能捞到关键字符串:
cd /compat/ubuntu/opt/office-raccoon/resources
grep -a -o 'office-raccoon://[^"'"'"'\\ ]*' app.asar | sort -u
office-raccoon://auth/callback
office-raccoon://auth/callback?code=private-code
office-raccoon://check-update?source=notification
office-raccoon://local-agent?id=1
office-raccoon://oauth/qixin?code=a&state=b
office-raccoon://skill-install?code=xxx
再把上下文捞出来(BSD grep 的 -o 配合 \{0,255\} 有上限,超过 255 会报 invalid repetition count(s),用 perl 更省事):
perl -0777 -ne 'while(/(.{0,200}auth\/callback.{0,200})/gs){print "$1\n=====\n"}' app.asar
于是拿到了完整设计:
- 主进程有
queueDesktopLogin(url),只认office-raccoon://auth/callback - Linux 上还有一个"兼容登录"分支:
supportsLoopbackLogin: process.platform === 'linux',走startDesktopLoginLoopback(),本地127.0.0.1:随机端口收 code - 网页侧(
/code/authorize页面)会根据参数决定跳哪儿
网页侧的逻辑(从站点 JS 里读出来的)大致是:
// desktop 模式且没有 desktop_callback → 跳自定义协议
const target = desktop && !desktopCallback
? 'office-raccoon://auth/callback'
: redirect;
target.searchParams.set('code', code);
target.searchParams.set('state', state);
loopback 模式下 redirect 必须匹配:
/^http:\/\/127\.0\.0\.1:[1-9][0-9]{3,4}\/auth\/callback$/
// 且 state 必须是 64 位 hex
结论很清楚:默认登录流 = 浏览器跳 office-raccoon://... → 桌面环境把 URL 交给客户端。而这一步在 chroot + linuxulator 里,恰好是最容易碎的环节。
四、第一个根因:桌面项缺 --no-sandbox
深链的落地方式是标准的 Linux 桌面集成:
/usr/share/applications/raccoon-ai.desktop
MimeType=x-scheme-handler/office-raccoon;x-scheme-handler/raccoon-work;
Exec=/opt/office-raccoon/raccoon-ai %U
浏览器把 office-raccoon://... 交给系统 → 系统按 MIME 找到这个桌面项 → 执行 Exec → 启动一个 raccoon-ai 副本 → Electron 的单实例机制把 argv 转给正在跑的主实例 → 主实例拿到 code,登录完成。
问题就在 Exec 这一行:它没有 --no-sandbox。
手工复现一下:
sudo chroot /compat/ubuntu /bin/bash -c \
'su skywalk -c "DISPLAY=:0 /opt/office-raccoon/raccoon-ai \"office-raccoon://auth/callback?code=probe\""'
输出:
[32931:1022/132803.451188:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:221]
Check failed: . : 无效的参数 (22)
Trace/breakpoint trap
进程直接 trap。也就是说:每次浏览器想把登录态交回来,拉起的都是一个秒崩的进程,回调自然永远到不了。
4.1 修法:包装脚本 + 用户级覆盖
不要改 /usr/share/applications/ 里的系统文件(客户端升级会被覆盖),用 XDG 的优先级机制在用户目录里同名覆盖。
① 包装脚本 /usr/local/bin/raccoon-deeplink:
#!/bin/sh
export NEXT_PUBLIC_DESKTOP_APP_MODE=online_plus
export NEXT_PUBLIC_DESKTOP_CLIENT_AUTH=true
export NEXT_PUBLIC_DESKTOP_TOKEN_BRIDGE=true
export NEXT_PUBLIC_DESKTOP_REMOTE_MAIN_SITE_URL=https://xiaohuanxiong.com
export NEXT_PUBLIC_DESKTOP_REMOTE_API_URL=https://xiaohuanxiong.com
exec /opt/office-raccoon/raccoon-ai --no-sandbox "$@"
那几个
NEXT_PUBLIC_DESKTOP_*环境变量不能少:缺了会让渲染层判定"desktop web login disabled",点了登录没反应。
② 用户级桌面项 ~/.local/share/applications/raccoon-ai.desktop:
[Desktop Entry]
Name=Raccoon Work
Name[zh_CN]=商汤小浣熊
Exec=/usr/local/bin/raccoon-deeplink %U
Terminal=false
Type=Application
Icon=raccoon-ai
StartupWMClass=raccoon-ai
MimeType=x-scheme-handler/office-raccoon;x-scheme-handler/raccoon-work;
Categories=Utility;
③ MIME 默认关联 ~/.config/mimeapps.list:
[Default Applications]
x-scheme-handler/office-raccoon=raccoon-ai.desktop
x-scheme-handler/raccoon-work=raccoon-ai.desktop
④ Firefox 免确认 (~/.config/mozilla/firefox/<profile>/user.js):
user_pref("network.protocol-handler.expose.office-raccoon", false);
user_pref("network.protocol-handler.warn-external.office-raccoon", false);
user_pref("network.protocol-handler.expose.raccoon-work", false);
user_pref("network.protocol-handler.warn-external.raccoon-work", false);
user.js 只在 Firefox 启动时读,改完必须完全退出再开。
改完验证:
xdg-mime query default x-scheme-handler/office-raccoon # 应输出 raccoon-ai.desktop
sudo chroot /compat/ubuntu /bin/bash -c \
'su skywalk -c "DISPLAY=:0 xdg-open \"office-raccoon://auth/callback?code=probe2\""'
日志里立刻出现:
[deep-link] startup dispatch { hasCallback: true }
[desktop-auth] callback received { hasCode: true, loggingIn: true }
[deep-link] second-instance dispatch { hasCallback: true, ready: true }
后面那句 login_with_authorization_code 返回 400(假 code),说明链路已经通了,只差真实 code。
五、第二个坑:EBADF,进程卡死不退出
改完兴冲冲去登录,还是没进去。这时候不能凭感觉,继续看进程表:
ps -axo pid,ppid,etime,args | grep '[r]accoon-ai --no-sandbox'
看到了这个:
34160 1 02:07 /opt/office-raccoon/raccoon-ai --no-sandbox office-raccoon://auth/callback?code=ac_Fk5PWg_...
34169 1 02:21 ...同名同 code...
34173 1 02:21 ...
34177 1 02:21 ...
34194 1 02:10 ...
两个信息量巨大的事实:
- 浏览器这边其实是好的 ------它拿着真实 code 成功调起了我们修好的包装脚本(
--no-sandbox在,URL 也在); - 这些实例没退出 ,一堆挂在
ppid=1上,说明 Electron 的单实例转交没完成,每个都卡住了。
再看日志,一堆:
Error: EBADF: bad file descriptor, write
at writeSync (node:fs)
at SyncWriteStream._write
at console.value
at transport.writeFn (.../electron-log/src/node/transports/console.js:60:7)
原因 :浏览器(经 GIO/桌面项)拉起外部程序时,会把子进程的 fd 1 / fd 2 指向 /dev/null 甚至直接关掉。而 electron-log 的 console transport 仍然对 stdout 调 write,fd 已关闭 → EBADF → 异常发生在启动早期的日志路径里 → 进程既没完成转交也没退出,就这么挂住了。
雪上加霜的是:Chromium 的 ProcessSingleton 竞争失败/异常清理时,会把这几个文件删掉:
~/.config/office-raccoon/SingletonCookie
~/.config/office-raccoon/SingletonLock -> fb98-<pid>
~/.config/office-raccoon/SingletonSocket -> /tmp/scoped_dirXXXX/SingletonSocket
锁文件没了,下一次启动就不会"转交",而是自己变成新的主实例,开个新窗口、继续卡,问题扩散。
5.1 修法
包装脚本里把输出重定向掉就行:
LOG=/tmp/raccoon-deeplink.log
exec /opt/office-raccoon/raccoon-ai --no-sandbox "$@" >>"$LOG" 2>&1
然后清理现场:
ps -axo pid,args | grep -E '[r]accoon-ai|[c]hrome_crashpad_handler' | awk '{print $1}' \
| xargs sudo kill -TERM
# 确认 Singleton* 三个文件已经消失,再用 raccoon-chroot 重新启动
ls -l ~/.config/office-raccoon/Singleton*
新实例启动后,锁应该指向它自己:
SingletonLock -> fb98-34730
5.2 怎么稳定复现"浏览器拉起"这种场景
关键是要模拟 fd 1/2 被关闭,否则本地终端里测永远是好的:
sudo chroot /compat/ubuntu /bin/bash -c \
'su skywalk -c "exec 1>&- 2>&-; DISPLAY=:0 /usr/local/bin/raccoon-deeplink \"office-raccoon://check-update?source=notification\""'
期望结果:日志出现 second-instance dispatch { ready: true },进程秒退,ps 里不留残余。这一条过了,深链才算真的修好。
六、最终登录走的是"兼容登录"
有意思的是,最后让我进到客户端里的,并不是深链,而是客户端登录页上那个不起眼的 "兼容登录" 按钮。
回头看代码就明白了:这个模式是专门给 Linux 准备的(supportsLoopbackLogin: process.platform === 'linux'),它不走自定义协议,而是:
- 客户端本地
listen(0, '127.0.0.1')起一个临时 HTTP 服务,生成 64 位 hexstate - 浏览器打开
.../authorize?...&desktop_callback=loopback&redirect=http://127.0.0.1:<port>/auth/callback&state=<state> - 登录成功后网页直接跳 http 本地地址,不涉及任何协议关联
- 客户端收到
GET /auth/callback?code=...&state=...,校验后换 token
日志证据:
[desktop-auth] loopback callback received
[desktop-auth] loopback login completed
服务端的校验还挺严格(host 头、remoteAddress == 127.0.0.1、sec-fetch-dest: document、state 必须 64 位 hex 且恒定时间比较),这也解释了为什么它比深链稳------它根本不依赖桌面环境。
它唯一的限制:接收器只活 5 分钟(timeoutMs ?? 300000),打开浏览器后得麻利点。
七、踩坑清单
| 坑 | 表现 | 解法 |
|---|---|---|
桌面项缺 --no-sandbox |
回调实例 FATAL zygote_host_impl_linux.cc:221,秒崩 |
包装脚本补 --no-sandbox |
| 浏览器关闭子进程 fd 1/2 | EBADF: bad file descriptor, write,进程挂死不退 |
包装脚本重定向 >>log 2>&1 |
| 卡死实例删掉 Singleton 锁 | 之后每次启动都新开窗口、登录更乱 | kill 干净 + 重启,别在脏状态下反复点登录 |
缺 NEXT_PUBLIC_DESKTOP_* |
点登录完全没反应 | 包装脚本里补齐 |
chroot 内 ~ 与宿主不同 |
找日志找不到 | 认准进程的 --user-data-dir / HOME |
BSD grep \{0,260\} |
invalid repetition count(s) |
上限 255,或用 perl 正则 |
改了 user.js 不生效 |
仍然弹"选择应用" | Firefox 必须完全退出重启 |
八、命令速查
# 看监听
sockstat -4l
# 看应用进程
ps -axo pid,ppid,etime,args | grep '[r]accoon-ai'
# 读日志
tail -f ~/.config/office-raccoon/logs/raccoon.log
# 查协议关联
xdg-mime query default x-scheme-handler/office-raccoon
# 手动喂一次深链
sudo chroot /compat/ubuntu /bin/bash -c \
'su skywalk -c "DISPLAY=:0 /usr/local/bin/raccoon-deeplink \"office-raccoon://auth/callback?code=...\""'
# 模拟浏览器的关闭 fd 场景
sudo chroot /compat/ubuntu /bin/bash -c \
'su skywalk -c "exec 1>&- 2>&-; DISPLAY=:0 /usr/local/bin/raccoon-deeplink \"office-raccoon://check-update\"'
九、小结
这类"网页登录成功但客户端没反应"的问题,在 Electron + Linux 桌面的组合里其实很常见,只是套上 FreeBSD 的 linuxulator 之后,每个环节都多了一点意外:
- Electron 的沙箱 :没有
--no-sandbox就活不下去,而且崩溃点在 zygote 子进程,主进程看不出来; - fd 继承:桌面环境/浏览器拉起外部程序时的 fd 状态,和你在终端里手敲完全不一样,测的时候一定要模拟;
- 单实例机制 :
SingletonLock是文件系统上的三件套,异常退出会留脏,脏了就会"越点越乱"; - 兜底设计:客户端自己留的 loopback "兼容登录"模式,反而是这种环境下最靠谱的路径。
希望这篇记录能帮到同样在 FreeBSD 上折腾桌面应用的朋友。有问题欢迎评论区交流。