tl;dr
LightDM 会错误设置 $LANG
等 locale 环境变量,造成如此配置的系统在 ssh 等传递 locale 相关环境变量情形下丧失与非 glibc 系统如 macOS 的兼容性。
考虑到 LightDM 整体上并没有什么核心竞争力,建议还是换用其他登陆管理器。
起因
我一直是一名苹果黑,直到公司给我配了一台 MacBook Pro(虽然这并没有改变我对苹果公司及其产品的整体看法)……因为所有同事也都在用,加上企业 QQ 等等一票应用 Linux 体验不存在或者极差的原因,我也就姑且配了下 Mac 环境。这涉及到判断系统种类并根据判断结果分支,对不同系统设置不同的环境变量等等:
local is_darwin=false
local is_linux=false
case `uname -s` in
Darwin) is_darwin=true ;;
Linux) is_linux=true ;;
esac
一切进行得还算顺利,直到设置 mosh 为止。
brew install mosh
之后,因为 Homebrew 默认安装到 /usr/local
PREFIX 下,需要传入 --server
指定 mosh 服务器程序路径,但还是不行:
$ mosh hanazono.lan --server=/usr/local/bin/mosh-server
setlocale: Bad file descriptor
setlocale: Bad file descriptor
mosh-server needs a UTF-8 native locale to run.
Unfortunately, the local environment (LC_CTYPE=zh_CN.utf8) specifies
the character set "US-ASCII",
The client-supplied environment (LC_CTYPE=zh_CN.utf8) specifies
the character set "US-ASCII".
LANG="zh_CN.utf8"
LC_COLLATE="C"
LC_CTYPE="C"
LC_MESSAGES="C"
LC_MONETARY="C"
LC_NUMERIC="C"
LC_TIME="C"
LC_ALL=
Connection to hanazono.lan closed.
/usr/bin/mosh: Did not find mosh server startup message. (Have you installed mosh on your server?)
这是为什么呢?
调查
很显然这是一个服务器不支持 UTF-8 的错误,问题在于现在是 2017 年,怎么可能还有系统不支持 UTF-8……况且从 Mac 里 mosh 其他机器也一切正常,那么问题就应该出在 Linux 一方。
我们看见出错信息里提到了 zh_CN.utf8
这个 locale:
$ locale
LANG=zh_CN.utf8
LC_CTYPE=zh_CN.utf8
LC_NUMERIC="zh_CN.utf8"
LC_TIME="zh_CN.utf8"
LC_COLLATE="zh_CN.utf8"
LC_MONETARY="zh_CN.utf8"
LC_MESSAGES="zh_CN.utf8"
LC_PAPER="zh_CN.utf8"
LC_NAME="zh_CN.utf8"
LC_ADDRESS="zh_CN.utf8"
LC_TELEPHONE="zh_CN.utf8"
LC_MEASUREMENT="zh_CN.utf8"
LC_IDENTIFICATION="zh_CN.utf8"
LC_ALL=
而 Mac 一侧的正常情况应该是:
$ locale
LANG="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
LC_CTYPE="zh_CN.UTF-8"
LC_MESSAGES="zh_CN.UTF-8"
LC_MONETARY="zh_CN.UTF-8"
LC_NUMERIC="zh_CN.UTF-8"
LC_TIME="zh_CN.UTF-8"
LC_ALL=
嗯……在 Linux 一侧把 LANG
和 LC_CTYPE
export
成 zh_CN.UTF-8
之后,果然一切正常了。不过退一万步讲,为何这里的 locale 设置从一开始会出问题?
我的 Linux 系统很久之前就换上了 systemd,systemd 会读取 /etc/locale.conf
来设置默认 locale 信息。问题在于它的设置没有问题:
LANG="zh_CN.UTF-8"
不得不提的是,这里用 eselect 或者 localectl 进行选择,默认出来也是 utf8
的字符集,此处的 UTF-8
是我手工修改的。
那为什么设置了也没用呢?
继续深入
既然正确的 locale 设置了也没用,但 systemd 源码却不这么认为(它忠实地转达了我们的设置),我们不妨来看一下有多少进程受到了影响:
for i in `ps -ef --no-headers | awk '{ print $2; }'`; do
sudo cat /proc/$i/environ | \
tr '\0' '\n' |
grep '\.utf8$' > /dev/null 2>&1 && echo $i
done > /tmp/123
ps -ef | grep -P "$(tr '\n' '|' < /tmp/123 | sed 's/\|$//')"
rm /tmp/123
随手撸了一个 quick-and-dirty 的检查脚本,看看哪些进程的环境变量里有 .utf8
这个片段。运行结果表明:
...
xenon 7382 1 0 15:16 ? 00:00:00 /usr/bin/gnome-keyring-daemon --daemonize --login
xenon 7384 7356 0 15:16 ? 00:00:00 /bin/sh /etc/xdg/xfce4/xinitrc -- /etc/X11/xinit/xserverrc
xenon 7401 1 0 15:16 ? 00:00:00 dbus-launch --autolaunch 62b624039cff8b48906360fb00000310 --binary-syntax --close-stderr
xenon 7402 1 0 15:16 ? 00:00:00 /usr/bin/dbus-daemon --fork --print-pid 5 --print-address 7 --session
xenon 7418 1 0 15:16 ? 00:00:00 /usr/bin/dbus-launch --exit-with-session startxfce4
xenon 7419 1 0 15:16 ? 00:00:01 /usr/bin/dbus-daemon --fork --print-pid 5 --print-address 7 --session
xenon 7427 7384 0 15:16 ? 00:00:09 xfce4-session
...
可以看见,所有用户登陆之前的进程都没有受影响,而从第一个启动的用户服务开始,整个 GUI 环境都被错误的 locale 变量污染了。可惜的是,这些进程的共同祖先已经结束运行了,否则就能直接看见罪魁祸首了;我们还需要进一步挖掘。
通过检查这些受影响进程的 /proc/PID/environ
可以看到,最早一批受影响的服务里面多了一个 GDM_LANG=zh_CN.utf8
的变量,而我并没有使用 gdm 而是 LightDM……情况已经比较清晰了。在绕了一些弯路之后,直接在 LightDM 的日志中找到了原因:
[+226739.95s] DEBUG: Greeter sets language zh_CN.utf8
问题出在 lightdm-gtk-greeter 这里。我有熟练的 grep 技巧,很快找到了最终负责拉取语言列表的代码:
/* lightdm-1.22.0/liblightdm-gobject/language.c:60 */
static void
update_languages (void)
{
gchar *command = "locale -a";
gchar *stdout_text = NULL, *stderr_text = NULL;
gint exit_status;
gboolean result;
GError *error = NULL;
if (have_languages)
return;
result = g_spawn_command_line_sync (command, &stdout_text, &stderr_text, &exit_status, &error);
/* ... */
}
嗯……这里调用了 locale -a
这个命令。很显然,作者默认它的输出中 utf8
才是正确的字符集写法,因为接下来就是
/* lightdm-1.22.0/liblightdm-gobject/language.c:95 */
/* Ignore the non-interesting languages */
if (strcmp (command, "locale -a") == 0 && !g_strrstr (code, ".utf8"))
continue;
这样的一个判断,可以看到代码丢掉了所有不以 .utf8
结尾的 locale。那么最后负责设置 GDM_LANG
和 LANG
等变量的代码就免责了,因为它们仅仅只是忠实地传达了最终来源于此处的信息而已:
/* lightdm-1.22.0/src/seat.c:1000 */
static void
configure_session (Session *session, SessionConfig *config, const gchar *session_name, const gchar *language)
{
/* ... */
if (language && language[0] != '\0')
{
session_set_env (session, "LANG", language);
session_set_env (session, "GDM_LANG", language);
}
}
/* xfce4-session@989cb9b83e xfce4-session/main.c:88 */
static void
setup_environment (void)
{
/* ... */
/* this is for compatibility with the GNOME Display Manager */
lang = g_getenv ("GDM_LANG");
if (lang != NULL && strlen (lang) > 0)
{
g_setenv ("LANG", lang, TRUE);
g_unsetenv ("GDM_LANG");
}
/* ... */
}
症结
那么“案件事实”应该说已经清楚了,我们把矛头指向 locale -a
:
$ locale -a
C
en_US.utf8
ja_JP.utf8
POSIX
zh_CN.gbk
zh_CN.utf8
zh_TW.utf8
果然……为了排除合理怀疑,我们看一下 locale 的配置:
# /etc/locale.gen
zh_CN.UTF-8 UTF-8
zh_CN.GBK GBK
zh_TW.UTF-8 UTF-8
en_US.UTF-8 UTF-8
ja_JP.UTF-8 UTF-8
并确定 locale archive 生成过程不背锅:
$ sudo strace -f -e execve locale-gen
...
[pid 25836] execve("/usr/bin/localedef", ["/usr/bin/localedef", "-c", "-i", "zh_CN", "-f", "UTF-8", "-A", "/usr/share/locale/locale.alias", "--prefix", "/", "zh_CN.UTF-8"], 0x18505d0 /* 22 vars */) = 0
...
而最后生成的 /usr/lib/locale/locale-archive
里面就是错误的名字了,用 strings 可以轻易验证。
注意力转向 localedef,其实现在问题的症结已经很明显了,因为无论是 locale
还是 localedef
都是 libc 的组件!果然:
/* Normalize codeset name. There is no standard for the codeset
names. Normalization allows the user to use any of the common
names. */
static const char *
normalize_codeset (const char *codeset, size_t name_len);
原来是 glibc 为了让用户不用死记硬背各种字符集名称的字母大小写、连字符加在哪,自己做了一层标准化,反正“字符集名称没有标准规定”……
其实就连官方都一直知道这意味着什么,以至于搜索 glibc locale name
第一个结果就是它……
Portability Note: With the notable exception of the standard locale names ‘C’ and ‘POSIX’, locale names are system-specific.
于是整个情况就是:
- 因为 glibc 内部根本无所谓各种字符集的原本拼写是什么反正都先标准化再用,导致
- glibc 的
locale -a
输出丧失与非 glibc 系统的互操作性,而 - Linux 生态系统各处都信赖自己的输入从而不做过多处理,于是
- glibc-特异性的 locale 设置一路渗透到了每一个 GUI 进程,最后
- 在 ssh 带着这些 locale 环境变量去敲 macOS 门的时候
- macOS 的 libc 炸裂了。
解决
按照把问题解决在最上游的原则,我们应该在哪里解决问题呢?显然我们不能修改 macOS 系统,因为它很封闭,locale 相关部分巨坑,并且我不熟悉;也显然不能在 glibc 层面,因为可能有很多应用都依赖 glibc 的这一“feature”了,那么就只能在 LightDM 一层动手了。
--- a/liblightdm-gobject/language.c 2017-08-19 18:02:33.773661324 +0800
+++ b/liblightdm-gobject/language.c 2017-08-19 18:09:08.886669723 +0800
@@ -96,8 +96,18 @@
if (strcmp (command, "locale -a") == 0 && !g_strrstr (code, ".utf8"))
continue;
- language = g_object_new (LIGHTDM_TYPE_LANGUAGE, "code", code, NULL);
+ /* Use the correct spelling of "UTF-8". */
+ size_t len_head = strlen (code) - 4;
+ size_t len_result = len_head + 6;
+ gchar *fixed_code = (gchar *) g_malloc (sizeof(gchar) * len_result);
+ strncpy (fixed_code, code, len_head);
+ strncpy (fixed_code + len_head, "UTF-8", 5);
+ *(fixed_code + len_result - 1) = '\0';
+
+ language = g_object_new (LIGHTDM_TYPE_LANGUAGE, "code", fixed_code, NULL);
languages = g_list_append (languages, language);
+
+ g_free (fixed_code);
}
g_strfreev (tokens);
可见就是很蠢的字符串末尾暴力替换……
还有一段小插曲,重新编译 lightdm 之后怎么注销重新登陆都没用,一度非常沮丧,最后发现 lightdm 主进程从开机之后就没有死过,相反它注视着 X server 来来去去,并随时生出一个子进程指定进去……这么做的一个副作用是任何 greeter 界面里的配置更改都要等到 lightdm 服务重启之后才能生效,导致我试图从 zh_CN.UTF-8
临时切换走时发现不能切换,又费了一番周折。
总之,在 systemctl restart lightdm
之后,一切重归平静……