返回博客

Node execFile 捕获不到子进程真实报错?错在只看 stderr

一个调用 Python 脚本做文件解析的后端接口,某天线上反馈报错信息完全看不懂。打开日志一看:

解析失败:Command failed: python3 /tmp/xxx/script.py /tmp/file.pdf

命令行截断,没有任何业务细节。SSH 上服务器手跑同一条命令,却能清楚地看到:

{"ok": false, "error": "markitdown_missing", "message": "Python package markitdown is not installed, run pip3 install markitdown first"}

那错误信息去哪了?

现象

  • 前端 / 日志只显示 Command failed: python3 ... 这条无用的包装信息
  • err.stderr 是空字符串
  • 手跑子进程有完整 JSON 输出,但 echo $? 返回 2(非零)
  • 无论怎么改日志格式,有效信息就是不出现

根因

Python(以及很多 CLI 工具)有一种常见的错误上报约定:把结构化错误 JSON 打印到 stdout,同时以非零退出码退出

try:
    from markitdown import MarkItDown
except Exception as exc:
    print(json.dumps({"ok": False, "error": "markitdown_missing", "message": "..."}))
    sys.exit(2)  # 非零退出

Node 的 child_process.execFile(包括 promisify(execFile) 异步版)遇到非零退出码会 reject Promise,reject 出来的 error 对象结构是:

| 字段 | 内容 | |---|---| | err.message | "Command failed: python3 /path/script.py /tmp/file" | | err.stderr | "" (脚本根本没写 stderr) | | err.stdout | '{"ok":false,"error":"markitdown_missing","message":"..."}' |

绝大多数 TypeScript catch 块只看 err.messageerr.stderrerr.stdout 被完全忽略——真实错误静默丢弃

坑:子进程把错误写在 stdout,Node catch 块却只读 stderr

这是双层错位的经典案例:子进程用「JSON on stdout + 非零退出」这种合理但非主流的约定;Node 封装层假设「错误在 stderr」这个主流约定。两边都"没错",合在一起就是信息黑洞。

err.stdout 在 Node execFile reject 的 error 对象上是存在的,但几乎所有教程、模板代码都不提它。

正解

catch 块同时解析 err.stdout,把它合并进错误分类的 haystack,再按优先级决定展示给用户的文案:

} catch (err: any) {
  const message = String(err?.message || err)
  const stderr = err?.stderr ? String(err.stderr).slice(0, 2000).trim() : ''
 
  // 子进程可能把结构化错误 JSON 打到 stdout + 非零退出
  // 不能让这份 JSON 被静默丢弃
  const stdout = err?.stdout ? String(err.stdout).slice(0, 4000).trim() : ''
  const stdoutPayload = stdout ? safeParseJson(stdout) : null
  const subprocessError = stdoutPayload?.error ? String(stdoutPayload.error) : ''
  const subprocessMessage = stdoutPayload?.message ? String(stdoutPayload.message) : ''
 
  // 把所有来源合并成 haystack,regex 分类才能匹配到子进程内部的错误码
  const haystack = `${message} ${stderr} ${subprocessError} ${subprocessMessage}`
  if (/markitdown_missing|No module named/.test(haystack)) {
    throw new Error('文档解析依赖未安装,请先在服务器执行 pip3 install markitdown')
  }
 
  // 展示优先级:子进程 message > stderr > Node 包装 message
  const detail = subprocessMessage || subprocessError || stderr || message
  throw new Error(`文件解析失败:${detail}`)
}
 
function safeParseJson(raw: string): any {
  try { return JSON.parse(raw) } catch { return null }
}
正解:catch 块同时读 err.stdout,safeParseJson 兜底

四个要点:

  1. slice 再 stringify:子进程崩溃时可能输出几 MB 乱码,2-4 KB 截断足够,不要撑爆响应体或日志。
  2. safeParseJson 不是 JSON.parse:stdout 可能是残缺 JSON 或纯文本,catch 块自身绝不能再抛异常。
  3. 先合并 haystack,再跑 regex:让现有的错误分类逻辑不用改,自然命中 markitdown_missing 等错误码。
  4. 展示优先级:子进程自己的 message 字段质量最高,只有它缺席才向上回退到 stderr / Node 包装信息。

排查方法

怀疑是这个问题时,两步确认:

# 1. 手跑子进程,观察 stdout / stderr / exit code
python3 script.py args
echo "exit: $?"
 
# 2. 在 Node catch 块临时打印所有字段
console.error('err.stdout:', err?.stdout)
console.error('err.stderr:', err?.stderr)

如果手跑时 stdout 有 JSON、stderr 为空、exit 非零,就是这个坑。

一句话外卖

Node execFile reject 出来的 error 对象携带 err.stdout,Python 等子进程把错误 JSON 打在 stdout 时 catch 块必须显式读它,否则你看到的永远只是那行毫无意义的 "Command failed"。