背景就是,最近有遇到,在 bash 下,加载了 rvm 后,设置了 set -e(这个命令就是,shell 遇到命令执行错误的时候会自动退出) ,然后执行 cd 到一个目录下,竟发生了神奇的一幕,shell 退出了。不甘心的我,继续设置了 set -x(shell 调试用,会打印每一个执行的命令),再执行 cd,竟发现 cd 被 hook 了,变成了一大串不知名的玩意,而罪魁祸首,就是 rvm。

于是开始了问题排查之路。

排查

cd 命令怎么了

从命令行打印的 log 看,cd 命令被 hook 成了 __zsh_like_cd,那么这是何时被 hook 的?经过简单地排查,将其锁定在初始化 rvm 中,从 ~/.rvm/script/rvm 文件中发现,在第一次加载这个文件时,会进而加载大量的其他脚本,而 cd 就是在这个阶段被 hook 成 __zsh_like_cd 的。

然后,__zsh_like_cd 做了什么?跟着 set -x 打印出来的 log 一步步排查,最终,错误发生在 __rvm_project_dir_check 这个函数中,为了方便理解,先简明说下 __zsh_like_cd 的作用,就是在 cd 命令执行之余,会判断新进的目录下是否有 Gemfile,如果有的话,就根据 Gemfile 里面的配置去更改 ruby 的版本。流程大致如下:

  1. 命令行执行 cd 切换目录
  2. 判断是否有 Gemfile,如果有,下一步
  3. 判断是否有 ruby 版本的设置,如果有,下一步
  4. 根据设置更改 ruby 版本

真正导致错误的就是上面的第三步,具体代码在这里:

  if
    [[ ! -s "$_found_file" ||
      "${_found_file}" == "$HOME/.rvmrc"
    ]]
  then
    _found_file=""
  elif
    [[ "${_found_file##*/}" == "Gemfile" ]] &&
    ! __rvm_grep    "^#ruby="  "$_found_file" >/dev/null &&
    ! __rvm_grep -E "^\s*ruby" "$_found_file" >/dev/null
  then
    _found_file=""
  fi
  
  __rvm_grep() { GREP_OPTIONS="" \command \grep "$@" || return $?; }

再具体下来就是这一步! __rvm_grep "^#ruby=" "$_found_file" >/dev/null,在执行的 grep 的时候,grep 找不到目标行的时候就会报错,如果我们在 set -e 的环境下,直接运行 grep 的话,报错了肯定也是会退出 shell 的,所以在 __rvm_grep 函数中特地加了|| return $?用于将 grep 执行结果返回到上一层,||的作用,就是一个“或”操作,当第一条命令执行失败时就会接着执行下一条,一般来说,如果我们设置了 set -e,又有这么一条带||的命令,bash 在执行第一条命令时,会忽略掉 set -e 的设置,保证第一条命令执行失败不会立刻退出 shell,而是执行||后面的命令。

既然如此,为什么在 __rvm_grep 这个函数中,执行 grep 错误了会导致退出?||为何会“失效”?

为何 || 失效了

既然确定了原因,那就可以直接把 __rvm_grep 拿出来直接分析为何会导致 shell 退出,期间观察到 grep 命令是通过 command 命令执行的,command 是 bash 的一个内置命令,它的作用与 builtin 类似,它后面也是跟着一个完整的命令,但是这些命令只能来自于内置命令或者环境变量中的命令,像函数这种,就会被 command 过滤掉。

最终拆出来的代码:

set -x
set -e
command grep "aaa" file.name || echo $?

以上的代码就会导致 shell 退出,||的作用没有得到发挥,这是为什么?从命令执行的 log 来看,并没有更多的信息,

+ command grep aaa INSTALL
+ grep aaa INSTALL

所以问题一定是出在了命令的实现上,像 grep 或 command,一般来说,shell 有几种不同的命令类型,

  1. bash 内置的命令,C 代码实现,与 bash 本身绑定的
  2. 可执行文件,一般是 C 代码实现,跟 bash 本身关系不大
  3. 脚本文件,一组内置命令与可执行文件的集合,会依次读取并最终执行内置命令和可执行文件

考虑到 grep 是一个可执行文件,并不是 bash 内置的命令,它内部的代码不一定能够直接将 shell 退出,所以将目光锁定在 command 这个内置命令上。先验证下:

set -x
set -e
grep "aaa" file.name || echo $?
command grep "aaa" file.name || echo $?

如果将 command 去掉,确实就不会导致 shell 退出了,所以问题就大致明了了,command 是一个内置命令,最终是会走到 C 代码中去执行后面的 grep 命令,并对 grep 的处理结果做了一些操作,也就是在这个阶段,如果我们设置了 set -e 并且 grep 执行失败了,command 就会触发失败并退出 shell,而忽略了 command 后面跟着的 ||

源码分析

既然目前的问题出在 command 上,那就只能从 bash 的源码开始了,看了下本地的 bash 版本是 3.2.57,于是从网上下载了这个版本的代码,command 是一个内置命令,它的 C 实现代码就在 command.def 中,入口函数为 command_builtin,下面大致列举下流程,

// command 命令入口,生成 command 结构体,交给 execute_command 执行
int
command_builtin (list)
     WORD_LIST *list;
{

#define COMMAND_BUILTIN_FLAGS (CMD_NO_FUNCTIONS | CMD_INHIBIT_EXPANSION | CMD_COMMAND_BUILTIN)

  command = make_bare_simple_command ();
  command->value.Simple->words = (WORD_LIST *)copy_word_list (list);
  command->value.Simple->redirects = (REDIRECT *)NULL;
  command->flags |= COMMAND_BUILTIN_FLAGS;
  command->value.Simple->flags |= COMMAND_BUILTIN_FLAGS;

  add_unwind_protect ((char *)dispose_command, command);
  builtin_error ("start command");
  result = execute_command (command);
  builtin_error ("end command");

  run_unwind_frame ("command_builtin");

  return (result);
}
// execute_command 内部继续将 command 交给 execute_command_internal 执行
int
execute_command (command)
     COMMAND *command;
{
  /* Just do the command, but not asynchronously. */
  builtin_error ("start command internal");
  result = execute_command_internal (command, 0, NO_PIPE, NO_PIPE, bitmap);
  builtin_error ("end command internal");

  return (result);
}
// execute_command_internal 的实现,它会根据 command 的类型,选择合适的执行方式,对于 command 命令,就是 simple 类型,会查找对应的可执行文件或内置命令去执行。
// 并对命令执行的结果进行进一步处理,且分析代码得之,使用 command 命令执行 grep 出错导致 shell 退出的代码就在这里
int
execute_command_internal (command, asynchronous, pipe_in, pipe_out,
			  fds_to_close)
     COMMAND *command;
     int asynchronous;
     int pipe_in, pipe_out;
     struct fd_bitmap *fds_to_close;
{
  int exec_result, invert, ignore_return, was_error_trap;
  REDIRECT *my_undo_list, *exec_undo_list;
  volatile int last_pid;
  volatile int save_line_number;

  switch (command->type)
    {
    case cm_simple:
      {
  builtin_error ("start command simple");
	exec_result =
	  execute_simple_command (command->value.Simple, pipe_in, pipe_out,
				  asynchronous, fds_to_close);
  builtin_error ("end command simple");
        
  builtin_error ("start chekc 1");
      if (was_error_trap && ignore_return == 0 && invert == 0
       && exec_result != EXECUTION_SUCCESS)
	{
	  last_command_exit_value = exec_result;
	  run_error_trap ();
	}

  builtin_error ("start chekc 2");
  builtin_error ("start chekc 2 %d, %d, %d, %d, %d, %d, %d", ignore_return, invert, posixly_correct, interactive, special_builtin_failed, exit_immediately_on_error, exec_result);
      if (ignore_return == 0 && invert == 0 &&
	  ((posixly_correct && interactive == 0 && special_builtin_failed) ||
	   (exit_immediately_on_error && (exec_result != EXECUTION_SUCCESS))))
	{
	  last_command_exit_value = exec_result;
  builtin_error ("start chekc 3");
	  run_pending_traps ();
  builtin_error ("start chekc 4");
	  jump_to_top_level (ERREXIT);
  builtin_error ("end chekc 4");
	}
  builtin_error ("end chekc 2");

      break;
  }
    currently_executing_command = (COMMAND *)NULL;
  return (last_command_exit_value);
}

以上为处理流程的部分代码,从代码的实现可以得知,执行命令command grep "aaa" file.name || echo $? 时,被 shell 的输入解析之后,就会讲 command 后面的 grep 命令传递到 command_builtin 中执行,在这里将其封装成了 COMMAND,包含命令的类型,以及命令的文字描述等信息,然后在 execute_command 中,就会将其按照一个标准地命令执行流程去执行,execute_command 中就会根据命令的名字,去查找它对应的函数(如果是一个内置命令)或者对应的可执行文件(如果是非内置命令),把后面的参数再传过去,并拿到最终的返回结果。

然后就是一些关于返回结果的使用。这里需要注意的变量就是 exit_immediately_on_error,这个变量就是我们通过 set -e 设置的,当调用 set -e 时,这个变量就会变成 1,而 set +e 会再将其更改为 0。set 也是一个内置命令,它的功能大概就是用来设置一些全局参数的,比如 e 为 exit_immediately_on_error,x 为 echo_command_at_execute。最终决定执行失败是否退出的 shell 的就是这个变量的值,一般情况下带上了||之后的命令执行的时候,是会在执行第一条命令时先把 exit_immediately_on_error 设置为 0 才对(相当于手动关掉了 set -e)。

经过重新编译源码打 log 发现了,在执行 command_builtin 的时候,exit_immediately_on_error 仍旧是 1,也就是说||在 command 这条命令执行时失效了,那么,为什么在执行 command 命令的时候却导致||无效了?这究竟是 bash 本身设计如此,还是条 bug?最终决定先探究一下源码。

说来也巧,在编译 bash 源码的时候发现,bash 并不能在 macOS 上正常编译,不知道是不兼容 darwin 还是别的原因,所以又装了个 linux 的虚拟机,但是在使用的时候发现 linux 的 bash 中,执行同样的command grep "aaa" file.name || echo $?并不会导致 shell 退出。这又是为什么?赶紧看了下 bash 的版本,发现 mac 上默认的 bash 是 3.2.57 的,而 linux 上默认安装都 5.0 了,这,差距确实大了点。

到底是谁的锅

那么根据现有的信息:bash 3.2.57 会出错,bash 5.0 不会出错,就基本可以确认是 bash 上的某个 bug 了,而根据此前的分析,exit_immediately_on_error 是起决定性作用的,那么直接跟踪下 exit_immediately_on_error 值的变化,然后看下两个版本的区别,大概率就能找到问题的原因了。

总共更改 exit_immediately_on_error 值的地方也没几处,很快,就发现了一处很可疑的代码,就是 execute_builtin 函数,

static int
execute_builtin (builtin, words, flags, subshell)
     sh_builtin_func_t *builtin;
     WORD_LIST *words;
     int flags, subshell;
{
  int old_e_flag, result, eval_unwind;
  int isbltinenv;

  old_e_flag = exit_immediately_on_error;
  /* The eval builtin calls parse_and_execute, which does not know about
     the setting of flags, and always calls the execution functions with
     flags that will exit the shell on an error if -e is set.  If the
     eval builtin is being called, and we're supposed to ignore the exit
     value of the command, we turn the -e flag off ourselves, then
     restore it when the command completes. */
   if (subshell == 0 && builtin == eval_builtin && (flags & CMD_IGNORE_RETURN))
    {
      begin_unwind_frame ("eval_builtin");
      unwind_protect_int (exit_immediately_on_error);
      exit_immediately_on_error = 0;
      eval_unwind = 1;
    }
  else
    eval_unwind = 0;

// 中间省略

  if (eval_unwind)
    {
      exit_immediately_on_error += old_e_flag;
      discard_unwind_frame ("eval_builtin");
    }

  return (result);
}

在这里判断了是否开启了子 shell、待执行命令是否是 eval_builtin、是否需要忽略返回值三个条件,如果成功,就保存下状态,将 exit_immediately_on_error 关掉,使得命令无论结果如何,都不会触发 shell 退出,但是从这也可以看到,只有当 builtin 命令是 eval_builtin 时才会执行这一步,而在 5.0 的代码中,又增加了两个 builtin 命令,变成了

  if (subshell == 0 && (flags & CMD_IGNORE_RETURN) &&
      (builtin == eval_builtin || builtin == command_builtin || builtin == source_builtin))
  {
    begin_unwind_frame("eval_builtin");
    unwind_protect_int(exit_immediately_on_error);
    unwind_protect_int(builtin_ignoring_errexit);
    error_trap = TRAP_STRING(ERROR_TRAP);
    if (error_trap)
    {
      error_trap = savestring(error_trap);
      add_unwind_protect(xfree, error_trap);
      add_unwind_protect(set_error_trap, error_trap);
      restore_default_signal(ERROR_TRAP);
    }
    exit_immediately_on_error = 0;
    ignexit_flag = builtin_ignoring_errexit;
    builtin_ignoring_errexit = 1;
    eval_unwind = 1;
  }
  else
    eval_unwind = 0;

这也就说明了 5.0 和 3.2 之间的区别的原因,并且经过验证,在 3.2 版本增加 builtin == command_builtin 判断,或者在 5.0 中移除这个判断,就会导致它们出现不同的结果,至此,也正式确定了最开始的,为什么 cd 命令会导致 shell 推出的原因。

那么再复盘下,这到底是谁的锅?

首先,rvm 的锅不可避免,它使用了 command grep 去执行 grep 命令,虽然它的本意应该是想避免这个命令被 hook,因为它就是在 hook 了 cd 之后才加的这些功能,但是却没有考虑到 command 命令的这样一个 bug。

其次,bash 也算是在 3.2 中有这么一个 bug,这当然不是就这么设计的,否则它也不会在 5.0 中对这里进行改动了。

最后,macOS 也有责任,bash 都更到 5.X 版本了,竟然还内置了这么旧一个版本的 bash,这就会导致 bash 可能修复的一些 bug 或者优化,无法在系统中得以体现。