Manjusaka

Manjusaka

關於 Node.js 中 execSync 的一點問題

很久沒寫水文了,昨天幫人查了一個 Node.js 中 execSync 這個函數特殊行為的問題,很有趣,所以大概記錄下來水一篇文章

背景#

首先老哥給了一張截圖

問題截圖

首先基本問題可以抽象為在 Node.js 中利用 execSync 這個函數執行 ps -Af | grep -q -E -c "\\-\\-user-data-dir=\\.+App" 這樣一條命令的時候,Node.js 時不時會報錯。具體堆棧大概為

Uncaught Error: Command failed: ps -Af | grep -q -E -c "\-\-user-data-dir=\.+App"
    at checkExecSyncError (child_process.js:616:11)
    at Object.execSync (child_process.js:652:15) {
  status: 1,
  signal: null,
  output: [ null, <Buffer >, <Buffer > ],
  pid: 89073,
  stdout: <Buffer >,
  stderr: <Buffer >
}

但是同樣的命令在終端上並不會有類似的現象。所以這個問題有點困擾人

分析#

首先先看一下 Node.js 文檔中對 execSync 的描述

The child_process.execSync() method is generally identical to child_process.exec() with the exception that the method will not return until the child process has fully closed. When a timeout has been encountered and killSignal is sent, the method won't return until the process has completely exited. If the child process intercepts and handles the SIGTERM signal and doesn't exit, the parent process will wait until the child process has exited.
If the process times out or has a non-zero exit code, this method will throw. The Error object will contain the entire result from child_process.spawnSync().
Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution.

大意就是,這個函數通過子進程來執行一個命令,在命令執行超時之前會一直等待。OK 沒有問題。那接下來,我們先來看一下上面提到的報錯堆棧以及 execSync 的實現代碼

function execSync(command, options) {
  const opts = normalizeExecArgs(command, options, null);
  const inheritStderr = !opts.options.stdio;

  const ret = spawnSync(opts.file, opts.options);

  if (inheritStderr && ret.stderr)
    process.stderr.write(ret.stderr);

  const err = checkExecSyncError(ret, opts.args, command);

  if (err)
    throw err;

  return ret.stdout;
}

function checkExecSyncError(ret, args, cmd) {
  let err;
  if (ret.error) {
    err = ret.error;
  } else if (ret.status !== 0) {
    let msg = 'Command failed: ';
    msg += cmd || ArrayPrototypeJoin(args, ' ');
    if (ret.stderr && ret.stderr.length > 0)
      msg += `\n${ret.stderr.toString()}`;
    // eslint-disable-next-line no-restricted-syntax
    err = new Error(msg);
  }
  if (err) {
    ObjectAssign(err, ret);
  }
  return err;
}

我們能看到,這裡 execSync 在執行完命令執行代碼後,會進入 checkExecSyncError 來檢查子進程的 Exit Status Code 是否為 0,不為 0 則認為命令執行出錯,然後拋出異常。

看起來沒有問題,那麼也就是我們執行命令的時候出錯了?那我們驗證下吧

對於這種涉及 Linux 下 Syscall 問題排查的工具(這個問題在 Mac 等環境下也存在,不過我為了方便排查,跑去 Linux 上復現了),除了 strace 好像也暫時找不到更成熟方便的工具了(雖然基於 eBPF 也能做,但是說實話自己現撸絕對沒 strace 的效果好。

那麼上命令

sudo strace -t -f -p $PID -o error_trace.txt

tips: 在使用 strace 的時候可以利用 -f 參數,可以 trace 被 trace 進程創建的子進程

好了執行命令,成功拿到整個 syscall 的調用鏈路,OK 開始分析

首先我們將目光很快定位到了最關鍵的部分(因為整個文件太長,有將近 4K 行,我就直接挑重點部分分析了)

...
894259 13:21:23 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f12d9465a50) = 896940
...
896940 13:21:23 execve("/bin/sh", ["/bin/sh", "-c", "ps -Af | grep -E -c \"\\-\\-user-da"...], 0x4aae230 /* 40 vars */ <unfinished ...>
...
896940 13:21:24 <... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 1}], 0, NULL) = 896942
896940 13:21:24 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=896942, si_uid=1000, si_status=1, si_utime=0, si_stime=0} ---
896940 13:21:24 rt_sigreturn({mask=[]}) = 896942
896940 13:21:24 exit_group(1)           = ?
896940 13:21:24 +++ exited with 1 +++

首先這裡科普一下,Node.js 中沒有直接使用 fork 來創建新的進程,而是使用 clone 來創建新的進程,至於兩者之間的差別,要詳細說的話,可以單獨水一篇超長文了(我先立個 flag)這裡先用官方的說法大概描述下

These system calls create a new ("child") process, in a manner similar to fork(2).
By contrast with fork(2), these system calls provide more precise control over what pieces of execution context are shared between the calling process and the child process. For example, using these system calls, the caller can control whether or not the two processes share the virtual address space, the table of file descriptors, and the table of signal handlers. These system calls also allow the new child process to be placed in separate namespaces(7).

用簡短的概括性話來描述就是,clone 提供了 fork 近似的語義,不過通過 clone , 開發者能更細粒度的控制進程 / 線程創建過程中的細節

OK, 這裡我們看到 894259 這個主進程通過 clone 創建了 896940 這個進程。在執行過程中,896940 這個進程利用 execve 這個 syscall 通過 sh (這是 execSync 的默認行為) 我們的命令 ps -Af | grep -q -E -c "\\-\\-user-data-dir=\\.+App"。 OK,我們也看到了,896940 在退出的時候,的確是以 1 的 exit code 退出的,和我們之前的分析一致。那么換句話說,在我們執行命令的時候,有 error 的出現。那么這裡的 error 出現在哪呢?

我們分析一下命令,如果熟悉常見 shell 的同學可能發現了,我們的命令中實際上使用了管道操作符 | ,不精確的說,當這個操作符出現的時候,前後兩個命令將分別在兩個進程執行,然後通過 pipe 進行 IPC。那么換句話說,我們可以很快定位這兩個進程,直接快速搜了一下文本

...
896941 13:21:23 execve("/bin/ps", ["ps", "-Af"], 0x564c16f6ec38 /* 40 vars */) = 0
...
896942 13:21:23 execve("/bin/grep", ["grep", "-E", "-c", "\\-\\-user-data-dir=\\.*"], 0x564c16f6ecb0 /* 40 vars */ <unfinished ...>
...
896941 13:21:24 <... exit_group resumed>) = ?
896941 13:21:24 +++ exited with 0 +++
...
896942 13:21:24 exit_group(1)           = ?
896942 13:21:24 +++ exited with 1 +++

OK,我們發現 896942 即執行 grep 的進程直接以 exit code 1 退出了。那么這是為什麼呢??看了下 grep 的官方文檔,,卧操,差點吐血

Normally, the exit status is 0 if selected lines are found and 1 otherwise. But the exit status is 2 if an error occurred, unless the -q or --quiet or --silent option is used and a selected line is found. Note, however, that POSIX only mandates, for programs such as grep, cmp, and diff, that the exit status in case of error be greater than 1; it is therefore advisable, for the sake of portability, to use logic that tests for this general condition instead of strict equality with 2.

如果 grep 沒有匹配到數據,那麼會以 1 作為 exit code 退出進程。。如果匹配到了,則 0 退出。。但是,但是,卧操,卧操。。按照標準語義,exit code 1 的含義難道不是 Operation not permitted 嗎??完全不按基本法出牌!

總結#

實際上通篇看了下來,我們可以總結出兩個原因

  1. Node.js 在對 POSIX 相關 API 進行抽象封裝的時候,直接按照了標準語義,給用戶兜底了。雖然從理論上講這應該是個應用自決的行為
  2. grep 沒有按照基本法辦事

說實話我也不知道怎麼去評價這兩方面誰更坑一點。按照前面所說麼處理子進程的 exit code 從理論上講這應該是個應用自決的行為,但是 Node.js 自己做了一層封裝,在節省用戶心智的同時,遇到一些非標場景,也會有不小的隱患了。。

只能說不斷根據不同的場景做 trade-off 吧

好了,這篇文章就到這裡了,因為是臨時起義,所以我就懶得將相關 Reference 列在文裡了。差不多這樣吧,水文目標達成.jpg

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。